Compare commits
21 Commits
b13905dade
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a66cad13a | ||
|
|
f008a9e665 | ||
|
|
ed612f3e38 | ||
|
|
dcd9e22b6f | ||
|
|
f1c8f08272 | ||
|
|
ffb6d679c0 | ||
|
|
ea90a860e9 | ||
|
|
bdf7a51675 | ||
|
|
c4b9509b5c | ||
|
|
92ea13a680 | ||
|
|
05cf7e98d9 | ||
|
|
9291b063d0 | ||
|
|
6f462c97e0 | ||
|
|
9ff2ce7ff2 | ||
|
|
fc1b23470e | ||
|
|
cb44dd8d0f | ||
|
|
1f41e1fa51 | ||
|
|
fa085339b8 | ||
|
|
5ad01dfd1d | ||
|
|
b72826bc46 | ||
|
|
459664825c |
30
.package
30
.package
@@ -1,6 +1,34 @@
|
||||
{
|
||||
required = {
|
||||
'platform',
|
||||
},
|
||||
title = "RemoteTurtle",
|
||||
description = "Web-based remote control for CC:Tweaked turtles with 3D visualization, D* Lite pathfinding, and state-machine AI. Includes turtle controller, GPS host, web bridge, and pocket computer programs.",
|
||||
repository = "gitea://git.spatulaa.com/MayaTheShy/remoteturtle/master/",
|
||||
exclude = { "^server/", "^client/", "%.md$", "%.yml$", "%.json$", "%.bat$", "%.sh$", "^Dockerfile", "^%.git", "^LICENSE$", "^node_modules/" },
|
||||
exclude = {
|
||||
"^server/", "^client/", "^__tests__/",
|
||||
"^startup_", "^start%.",
|
||||
"%.md$", "%.yml$", "%.json$", "%.bat$", "%.sh$",
|
||||
"^Dockerfile", "^%.git", "^LICENSE$", "^node_modules/",
|
||||
},
|
||||
install = [[
|
||||
local pkgDir = fs.combine("packages", "remoteturtle")
|
||||
|
||||
-- Web Bridge config
|
||||
print("")
|
||||
print("-- RemoteTurtle Web Bridge Setup --")
|
||||
print("")
|
||||
write("Server URL (e.g. http://192.168.1.10:4200): ")
|
||||
local serverUrl = read()
|
||||
if serverUrl and #serverUrl > 0 then
|
||||
local wsUrl = serverUrl:gsub("^http", "ws") .. "/ws/bridge"
|
||||
local cfg = textutils.serialiseJSON({ serverUrl = serverUrl, wsUrl = wsUrl })
|
||||
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||
f.write(cfg)
|
||||
f.close()
|
||||
print("Saved web bridge config.")
|
||||
else
|
||||
print("Skipped — edit .webbridge_config later.")
|
||||
end
|
||||
]],
|
||||
}
|
||||
|
||||
@@ -64,6 +64,36 @@ You should see:
|
||||
**GPS not working?**
|
||||
- Set up 4 GPS host computers at high altitude
|
||||
- Run `gps host X Y Z` on each (with their coordinates)
|
||||
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
|
||||
turtle can self-build a complete GPS constellation (replaces 4 host computers)
|
||||
|
||||
## Pathfinding
|
||||
|
||||
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
|
||||
You can trigger it from the web UI via eval commands:
|
||||
|
||||
```lua
|
||||
-- Navigate to coordinates (avoids obstacles)
|
||||
_pathfind.goto(100, 65, -200)
|
||||
|
||||
-- Navigate with block digging enabled
|
||||
_pathfind.goto(100, 65, -200, { dig = true })
|
||||
|
||||
-- Go home
|
||||
_pathfind.goHome()
|
||||
|
||||
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
|
||||
_pathfind.face(3)
|
||||
|
||||
-- Get current heading name
|
||||
_pathfind.headingName() -- "east"
|
||||
```
|
||||
|
||||
The pathfinder:
|
||||
- Uses GPS for initial position, then tracks movement locally
|
||||
- Auto-detects heading on startup via GPS triangulation
|
||||
- Handles obstacles by trying to go over/around them
|
||||
- Supports optional block digging for clearing paths
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
11
etc/apps.db
11
etc/apps.db
@@ -3,6 +3,7 @@
|
||||
title = "Turtle Controller",
|
||||
category = "RemoteTurtle",
|
||||
run = "turtle.lua",
|
||||
requires = "turtle",
|
||||
},
|
||||
[ "rt_gps_host" ] = {
|
||||
title = "GPS Host",
|
||||
@@ -14,16 +15,22 @@
|
||||
category = "RemoteTurtle",
|
||||
run = "webbridge.lua",
|
||||
},
|
||||
[ "rt_pocket_control" ] = {
|
||||
title = "Pocket Control",
|
||||
category = "RemoteTurtle",
|
||||
run = "pocketcontrol.lua",
|
||||
requires = "pocket",
|
||||
},
|
||||
[ "rt_pocket_remote" ] = {
|
||||
title = "Pocket Remote",
|
||||
category = "RemoteTurtle",
|
||||
run = "pocketremote.lua",
|
||||
requires = { pocket = true },
|
||||
requires = "pocket",
|
||||
},
|
||||
[ "rt_pocket_gps" ] = {
|
||||
title = "Pocket GPS",
|
||||
category = "RemoteTurtle",
|
||||
run = "pocketgps.lua",
|
||||
requires = { pocket = true },
|
||||
requires = "pocket",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
-- Combines turtle control, GPS tracking, server management, and webbridge control
|
||||
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
||||
|
||||
local CHANNEL_SEND = 100
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
|
||||
|
||||
-- Find modem
|
||||
local modem = peripheral.find("modem")
|
||||
@@ -19,9 +21,12 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
modem.open(POCKET_CHANNEL)
|
||||
local WebBridge = require('platform.webbridge')
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
'remoteturtle.pocket',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
@@ -574,7 +579,9 @@ parallel.waitForAny(
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102/103) and target (4212/4213) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
if message.type == "status" then
|
||||
-- Update turtle list
|
||||
local found = false
|
||||
@@ -590,7 +597,7 @@ parallel.waitForAny(
|
||||
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
||||
end
|
||||
end
|
||||
elseif channel == POCKET_CHANNEL and type(message) == "table" then
|
||||
elseif Channels.match('remoteturtle.pocket', channel) and type(message) == "table" then
|
||||
-- Handle responses from webbridge
|
||||
if message.type == "webbridge_status" then
|
||||
webbridgeStatus = message.data
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
-- Live GPS Tracker for Pocket Computer
|
||||
-- Shows your current location in real-time
|
||||
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
-- Setup modem
|
||||
local modem = peripheral.find("modem")
|
||||
@@ -15,7 +18,7 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(STATUS_CHANNEL)
|
||||
WebBridge.openChannels(modem, { 'remoteturtle.status' })
|
||||
|
||||
local w, h = term.getSize()
|
||||
local myID = os.getComputerID()
|
||||
@@ -228,7 +231,9 @@ local function main()
|
||||
local replyChannel = param3
|
||||
local message = param4
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
handleStatus(message)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
|
||||
-- Monitor and control autonomous mining turtles
|
||||
|
||||
local CHANNEL_SEND = 100
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
@@ -15,8 +18,10 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
@@ -626,10 +631,12 @@ parallel.waitForAny(
|
||||
end,
|
||||
function()
|
||||
-- Status receiver
|
||||
-- Uses Channels.match() for dual-mode safety: accepts status on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
|
||||
-- Update or add turtle
|
||||
local found = false
|
||||
for i, t in ipairs(turtles) do
|
||||
@@ -655,7 +662,7 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
draw()
|
||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" then
|
||||
elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
|
||||
-- State change confirmation or other response
|
||||
if message.type == "state_changed" and message.turtleID then
|
||||
for i, t in ipairs(turtles) do
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
# Node.js backend
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Node.js backend
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --omit=dev
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
# Development Dockerfile with hot reload
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Development with hot reload
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Install nodemon for hot reload
|
||||
RUN npm install -g nodemon
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install all dependencies (including dev)
|
||||
RUN npm install
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cc-platform/server": "file:../../cc-platform-core/server",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
|
||||
118
server/server.js
118
server/server.js
@@ -1,41 +1,27 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import { createRequire } from 'module';
|
||||
import * as db from './database.js';
|
||||
import { Turtle } from './Turtle.js';
|
||||
import { TaskDispatcher } from './TaskDispatcher.js';
|
||||
import { WorldBlockCache } from './WorldBlockCache.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createPlatformServer,
|
||||
setupGracefulShutdown,
|
||||
createProxyEndpoint,
|
||||
} = require('@cc-platform/server');
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// ========== API Key Authentication ==========
|
||||
const API_KEY = process.env.API_KEY || '';
|
||||
|
||||
function extractApiKey(req) {
|
||||
const auth = req.headers.authorization || '';
|
||||
if (auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return req.headers['x-api-key'] || req.query.key || '';
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!API_KEY) return next(); // Auth disabled when no key configured
|
||||
if (extractApiKey(req) === API_KEY) return next();
|
||||
return res.status(401).json({ error: 'Unauthorized — invalid or missing API key' });
|
||||
}
|
||||
|
||||
// Protect mutating endpoints when an API key is set
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
return requireAuth(req, res, next);
|
||||
// ========== Platform Server Setup ==========
|
||||
const { app, server, auth, start, port } = createPlatformServer({
|
||||
serviceName: 'turtle-control',
|
||||
port: 3001,
|
||||
cors: true,
|
||||
});
|
||||
|
||||
const { requireAuth } = auth;
|
||||
const API_KEY = process.env.API_KEY || '';
|
||||
|
||||
// Rewrite requests that arrive without /api prefix (from reverse proxy stripping it)
|
||||
app.use((req, res, next) => {
|
||||
if (!req.path.startsWith('/api') && req.path !== '/' && req.path !== '/health') {
|
||||
@@ -215,19 +201,12 @@ function getBlockPosition(turtlePos, facing, direction) {
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket server for web clients AND bridge connections
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// Track bridge connections separately
|
||||
const bridgeClients = new Set();
|
||||
|
||||
console.log(`🚀 Turtle Control Server starting...`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
||||
console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
|
||||
|
||||
/**
|
||||
* Push a command to the webbridge for a specific turtle.
|
||||
* If a bridge is connected via WebSocket, send instantly.
|
||||
@@ -1880,8 +1859,6 @@ app.post('/api/turtle/:id/refresh-inventory', async (req, res) => {
|
||||
// ========== Cross-Project Integration API ==========
|
||||
// These endpoints allow the Inventory Manager system to query turtle state
|
||||
|
||||
const INVENTORY_SERVER_URL = process.env.INVENTORY_SERVER_URL || ''; // e.g. http://inventory-server:3001
|
||||
|
||||
// Get all turtle summaries (for inventory dashboard sidebar widget)
|
||||
app.get('/api/integration/turtle-summary', (req, res) => {
|
||||
const summaries = [];
|
||||
@@ -1902,62 +1879,29 @@ app.get('/api/integration/turtle-summary', (req, res) => {
|
||||
});
|
||||
|
||||
// Proxy to inventory server (locate items for turtle pickup)
|
||||
app.get('/api/integration/inventory-locate', async (req, res) => {
|
||||
if (!INVENTORY_SERVER_URL) {
|
||||
return res.json({ configured: false, message: 'INVENTORY_SERVER_URL not configured' });
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams(req.query);
|
||||
const resp = await fetch(`${INVENTORY_SERVER_URL}/api/integration/locate-item?${params}`);
|
||||
const data = await resp.json();
|
||||
res.json({ configured: true, ...data });
|
||||
} catch (err) {
|
||||
res.status(502).json({ configured: true, error: `Cannot reach inventory server: ${err.message}` });
|
||||
}
|
||||
});
|
||||
createProxyEndpoint(app, '/api/integration/inventory-locate', 'INVENTORY_SERVER_URL', '/api/integration/locate-item');
|
||||
|
||||
// Proxy to inventory server (low stock alerts — turtles could auto-mine missing resources)
|
||||
app.get('/api/integration/inventory-alerts', async (req, res) => {
|
||||
if (!INVENTORY_SERVER_URL) {
|
||||
return res.json({ configured: false, message: 'INVENTORY_SERVER_URL not configured' });
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${INVENTORY_SERVER_URL}/api/integration/low-stock`);
|
||||
const data = await resp.json();
|
||||
res.json({ configured: true, ...data });
|
||||
} catch (err) {
|
||||
res.status(502).json({ configured: true, error: `Cannot reach inventory server: ${err.message}` });
|
||||
}
|
||||
});
|
||||
createProxyEndpoint(app, '/api/integration/inventory-alerts', 'INVENTORY_SERVER_URL', '/api/integration/low-stock');
|
||||
|
||||
// Proxy to inventory server (storage space check — should turtles keep mining?)
|
||||
app.get('/api/integration/storage-status', async (req, res) => {
|
||||
if (!INVENTORY_SERVER_URL) {
|
||||
return res.json({ configured: false, message: 'INVENTORY_SERVER_URL not configured' });
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${INVENTORY_SERVER_URL}/api/integration/storage-status`);
|
||||
const data = await resp.json();
|
||||
res.json({ configured: true, ...data });
|
||||
} catch (err) {
|
||||
res.status(502).json({ configured: true, error: `Cannot reach inventory server: ${err.message}` });
|
||||
}
|
||||
createProxyEndpoint(app, '/api/integration/storage-status', 'INVENTORY_SERVER_URL', '/api/integration/storage-status');
|
||||
|
||||
// ========== Graceful Shutdown & Start ==========
|
||||
|
||||
setupGracefulShutdown({
|
||||
serviceName: 'turtle-control',
|
||||
cleanup: [
|
||||
() => taskDispatcher.stop(),
|
||||
() => db.closeDatabase(),
|
||||
],
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Shutting down server...');
|
||||
taskDispatcher.stop();
|
||||
db.closeDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`✅ Server ready!`);
|
||||
start(() => {
|
||||
console.log(`\nConfigured turtles to send updates to:`);
|
||||
console.log(` http://localhost:${PORT}/api/turtle/update`);
|
||||
if (INVENTORY_SERVER_URL) {
|
||||
console.log(`📦 Inventory server integration: ${INVENTORY_SERVER_URL}`);
|
||||
console.log(` http://localhost:${port}/api/turtle/update`);
|
||||
if (process.env.INVENTORY_SERVER_URL) {
|
||||
console.log(`📦 Inventory server integration: ${process.env.INVENTORY_SERVER_URL}`);
|
||||
}
|
||||
// Start task dispatcher after server is ready
|
||||
taskDispatcher.start();
|
||||
|
||||
228
turtle.lua
228
turtle.lua
@@ -3,9 +3,11 @@
|
||||
-- This script only handles: eval execution, status broadcasting,
|
||||
-- GPS tracking, inventory/peripheral events.
|
||||
|
||||
local CHANNEL_RECEIVE = 100
|
||||
local CHANNEL_SEND = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
-- State tracking (lightweight - server drives everything)
|
||||
local state = {
|
||||
@@ -23,11 +25,13 @@ local config = {
|
||||
}
|
||||
|
||||
-- Check for modem
|
||||
local modem = peripheral.find("modem")
|
||||
local WebBridge = require('platform.webbridge')
|
||||
local modem, modemSide = WebBridge.findModem(true)
|
||||
if not modem then
|
||||
error("No wireless modem found!")
|
||||
end
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
-- Open command channel (respects dual-mode migration)
|
||||
WebBridge.openChannels(modem, { 'remoteturtle.command' })
|
||||
|
||||
print("Server-Driven Turtle v5 (Pure Eval Protocol)")
|
||||
print("ID: " .. os.getComputerID())
|
||||
@@ -203,7 +207,215 @@ end
|
||||
|
||||
syncHomeWithServer()
|
||||
updateFuel()
|
||||
print("Ready! Turtle " .. os.getComputerID() .. " online (v5 server-driven)")
|
||||
|
||||
-- ========== Movement Wrapping (heading + position tracking) ==========
|
||||
|
||||
-- Heading: 0=south(+z), 1=west(-x), 2=north(-z), 3=east(+x)
|
||||
local MOVE_DELTA = {
|
||||
[0] = { x = 0, z = 1 }, -- south
|
||||
[1] = { x = -1, z = 0 }, -- west
|
||||
[2] = { x = 0, z = -1 }, -- north
|
||||
[3] = { x = 1, z = 0 }, -- east
|
||||
}
|
||||
|
||||
local _rawForward = turtle.forward
|
||||
local _rawBack = turtle.back
|
||||
local _rawUp = turtle.up
|
||||
local _rawDown = turtle.down
|
||||
local _rawTurnLeft = turtle.turnLeft
|
||||
local _rawTurnRight = turtle.turnRight
|
||||
|
||||
turtle.forward = function()
|
||||
local ok, reason = _rawForward()
|
||||
if ok and state.position then
|
||||
local d = MOVE_DELTA[state.facing]
|
||||
state.position.x = state.position.x + d.x
|
||||
state.position.z = state.position.z + d.z
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.back = function()
|
||||
local ok, reason = _rawBack()
|
||||
if ok and state.position then
|
||||
local d = MOVE_DELTA[state.facing]
|
||||
state.position.x = state.position.x - d.x
|
||||
state.position.z = state.position.z - d.z
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.up = function()
|
||||
local ok, reason = _rawUp()
|
||||
if ok and state.position then
|
||||
state.position.y = state.position.y + 1
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.down = function()
|
||||
local ok, reason = _rawDown()
|
||||
if ok and state.position then
|
||||
state.position.y = state.position.y - 1
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.turnLeft = function()
|
||||
local ok = _rawTurnLeft()
|
||||
if ok then
|
||||
state.facing = (state.facing + 3) % 4
|
||||
_G._turtleFacing = state.facing
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
turtle.turnRight = function()
|
||||
local ok = _rawTurnRight()
|
||||
if ok then
|
||||
state.facing = (state.facing + 1) % 4
|
||||
_G._turtleFacing = state.facing
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Detect heading via GPS triangulation
|
||||
local function detectHeading()
|
||||
local x1, _, z1 = gps.locate(2)
|
||||
if not x1 then return false end
|
||||
if _rawForward() then
|
||||
local x2, _, z2 = gps.locate(2)
|
||||
_rawBack()
|
||||
if x2 then
|
||||
local dx = math.floor(x2) - math.floor(x1)
|
||||
local dz = math.floor(z2) - math.floor(z1)
|
||||
if dz > 0 then state.facing = 0 -- south
|
||||
elseif dz < 0 then state.facing = 2 -- north
|
||||
elseif dx > 0 then state.facing = 3 -- east
|
||||
elseif dx < 0 then state.facing = 1 -- west
|
||||
end
|
||||
_G._turtleFacing = state.facing
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if state.position then
|
||||
if detectHeading() then
|
||||
print("Heading: " .. ({"south","west","north","east"})[state.facing + 1])
|
||||
else
|
||||
print("Heading: unknown (blocked)")
|
||||
end
|
||||
end
|
||||
|
||||
-- ========== Pathfinding Module ==========
|
||||
|
||||
local pathfind = {}
|
||||
|
||||
--- Face a target heading.
|
||||
function pathfind.face(targetH)
|
||||
targetH = targetH % 4
|
||||
while state.facing ~= targetH do
|
||||
local diff = (targetH - state.facing) % 4
|
||||
if diff == 1 then
|
||||
turtle.turnRight()
|
||||
elseif diff == 3 then
|
||||
turtle.turnLeft()
|
||||
else
|
||||
turtle.turnRight()
|
||||
turtle.turnRight()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Navigate to target coordinates with simple obstacle avoidance.
|
||||
-- @param tx, ty, tz target position
|
||||
-- @param options { dig = false, maxAttempts = 256 }
|
||||
-- @return true or false, error
|
||||
function pathfind.goto(tx, ty, tz, options)
|
||||
options = options or {}
|
||||
local maxAttempts = options.maxAttempts or 256
|
||||
local dig = options.dig or false
|
||||
local attempts = 0
|
||||
|
||||
while attempts < maxAttempts do
|
||||
local pos = state.position
|
||||
if not pos then return false, "No GPS position" end
|
||||
|
||||
-- Arrived?
|
||||
if pos.x == tx and pos.y == ty and pos.z == tz then
|
||||
return true
|
||||
end
|
||||
|
||||
attempts = attempts + 1
|
||||
local moved = false
|
||||
|
||||
-- Priority: Y first (get to correct height), then X, then Z
|
||||
if not moved and pos.y ~= ty then
|
||||
if pos.y < ty then
|
||||
moved = turtle.up()
|
||||
if not moved and dig then turtle.digUp(); moved = turtle.up() end
|
||||
else
|
||||
moved = turtle.down()
|
||||
if not moved and dig then turtle.digDown(); moved = turtle.down() end
|
||||
end
|
||||
end
|
||||
|
||||
if not moved and pos.x ~= tx then
|
||||
pathfind.face(tx > pos.x and 3 or 1)
|
||||
moved = turtle.forward()
|
||||
if not moved and dig then turtle.dig(); moved = turtle.forward() end
|
||||
end
|
||||
|
||||
if not moved and pos.z ~= tz then
|
||||
pathfind.face(tz > pos.z and 0 or 2)
|
||||
moved = turtle.forward()
|
||||
if not moved and dig then turtle.dig(); moved = turtle.forward() end
|
||||
end
|
||||
|
||||
-- Obstacle avoidance: try going around
|
||||
if not moved then
|
||||
if turtle.up() then
|
||||
moved = true
|
||||
elseif turtle.down() then
|
||||
moved = true
|
||||
else
|
||||
turtle.turnRight()
|
||||
if turtle.forward() then
|
||||
moved = true
|
||||
else
|
||||
turtle.turnLeft()
|
||||
turtle.turnLeft()
|
||||
if turtle.forward() then
|
||||
moved = true
|
||||
else
|
||||
turtle.turnRight() -- restore heading
|
||||
return false, "Stuck at " .. pos.x .. "," .. pos.y .. "," .. pos.z
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return false, "Max attempts exceeded"
|
||||
end
|
||||
|
||||
--- Go home (to saved home position).
|
||||
function pathfind.goHome(options)
|
||||
if not state.homePosition then return false, "No home position set" end
|
||||
return pathfind.goto(state.homePosition.x, state.homePosition.y, state.homePosition.z, options)
|
||||
end
|
||||
|
||||
--- Get current heading name.
|
||||
function pathfind.headingName()
|
||||
return ({"south","west","north","east"})[state.facing + 1]
|
||||
end
|
||||
|
||||
-- Expose globally for eval access
|
||||
_G._pathfind = pathfind
|
||||
|
||||
print("Ready! Turtle " .. os.getComputerID() .. " online (v5 server-driven + pathfinding)")
|
||||
broadcastStatus()
|
||||
|
||||
-- ========== Main Loop ==========
|
||||
@@ -234,9 +446,11 @@ parallel.waitForAny(
|
||||
|
||||
function()
|
||||
-- Command processing (eval protocol)
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (100) and target (4210) channels during migration.
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
if channel == CHANNEL_RECEIVE then
|
||||
if Channels.match('remoteturtle.command', channel) then
|
||||
processMessage(channel, message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,16 +2,43 @@
|
||||
-- Connects to server via WebSocket for instant bidirectional communication.
|
||||
-- Forwards turtle modem messages to server and server commands to turtles.
|
||||
-- Falls back to HTTP polling if WebSocket is unavailable.
|
||||
--
|
||||
-- Uses cc-platform-core for shared infrastructure (config, HTTP, modem, channels).
|
||||
|
||||
local SERVER_URL = "http://beta:4200"
|
||||
local WS_URL = "ws://beta:4200/ws/bridge"
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local COMMAND_CHANNEL = 100
|
||||
local POCKET_CHANNEL = 103
|
||||
local WebBridge = require('platform.webbridge')
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
-- Find peripherals
|
||||
local modem = peripheral.find("modem")
|
||||
-------------------------------------------------
|
||||
-- Configuration (via platform)
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
|
||||
local config, configSource = WebBridge.loadConfig({
|
||||
serverUrl = "http://localhost:4200",
|
||||
wsUrl = nil, -- derived from serverUrl if not set
|
||||
}, {
|
||||
fs.combine(_baseDir, ".webbridge_config"),
|
||||
})
|
||||
|
||||
local SERVER_URL = config.serverUrl
|
||||
local WS_URL = config.wsUrl or SERVER_URL:gsub("^http", "ws") .. "/ws/bridge"
|
||||
|
||||
if configSource then
|
||||
print("[CONFIG] Loaded from " .. configSource)
|
||||
end
|
||||
|
||||
-- Channels from platform registry
|
||||
local COMMAND_CHANNEL = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
|
||||
|
||||
-------------------------------------------------
|
||||
-- Peripherals
|
||||
-------------------------------------------------
|
||||
|
||||
local modem, modemSide = WebBridge.findModem(true) -- prefer wireless
|
||||
local monitor = peripheral.find("monitor")
|
||||
|
||||
if not modem then
|
||||
@@ -23,9 +50,12 @@ if hasMonitor then
|
||||
monitor.setTextScale(0.5)
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
modem.open(POCKET_CHANNEL)
|
||||
-- Open channels via platform (respects dual-mode migration)
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
'remoteturtle.pocket',
|
||||
})
|
||||
|
||||
-- Track turtles and stats
|
||||
local turtles = {}
|
||||
@@ -177,32 +207,24 @@ local function wsSend(data)
|
||||
return false
|
||||
end
|
||||
|
||||
-- ========== HTTP Fallback Functions ==========
|
||||
-- ========== HTTP Helpers (via platform) ==========
|
||||
|
||||
local function httpPost(path, data)
|
||||
local success, result = pcall(function()
|
||||
local response = http.post(SERVER_URL .. path, textutils.serializeJSON(data), { ["Content-Type"] = "application/json" })
|
||||
if response then
|
||||
local content = response.readAll()
|
||||
response.close()
|
||||
return textutils.unserializeJSON(content)
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
return success and result or nil
|
||||
local result = WebBridge.httpPost(SERVER_URL .. path, data)
|
||||
if result then
|
||||
local ok, parsed = pcall(textutils.unserialiseJSON, result)
|
||||
if ok then return parsed end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function httpGet(path)
|
||||
local success, result = pcall(function()
|
||||
local response = http.get(SERVER_URL .. path)
|
||||
if response then
|
||||
local content = response.readAll()
|
||||
response.close()
|
||||
return textutils.unserializeJSON(content)
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
return success and result or nil
|
||||
local result = WebBridge.httpGet(SERVER_URL .. path)
|
||||
if result then
|
||||
local ok, parsed = pcall(textutils.unserialiseJSON, result)
|
||||
if ok then return parsed end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ========== Forward Turtle Modem Message to Server ==========
|
||||
@@ -469,11 +491,13 @@ parallel.waitForAny(
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
stats.messagesReceived = stats.messagesReceived + 1
|
||||
|
||||
if channel == STATUS_CHANNEL or channel == CHANNEL_RECEIVE then
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (101/102/103) and target (4211/4212/4213) during migration.
|
||||
if (Channels.match('remoteturtle.status', channel) or Channels.match('remoteturtle.response', channel)) then
|
||||
if type(message) == "table" then
|
||||
forwardToServer(message)
|
||||
end
|
||||
elseif channel == POCKET_CHANNEL then
|
||||
elseif Channels.match('remoteturtle.pocket', channel) then
|
||||
if type(message) == "table" then
|
||||
handlePocketMessage(message)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user