Compare commits

..

21 Commits

Author SHA1 Message Date
MayaTheShy
0a66cad13a fix(docker): clone platform from git instead of additional_contexts
The additional_contexts approach required cc-platform-core to exist on
the Docker host at a relative path. This fails on servers where the
repo layout differs. Instead, use a multi-stage build: stage 1 clones
cc-platform-core from Gitea (depth 1), stage 2 copies server/ into the
app and rewrites the file: path. Fully self-contained — no host deps.
Applied to both production and dev Dockerfiles.
2026-03-28 22:38:07 -04:00
MayaTheShy
f008a9e665 fix(docker): resolve @cc-platform/server file: dep in container build
Use additional_contexts to copy platform server package into the Docker
build context. Rewrites the file: dependency path and removes the
lockfile so npm install can resolve the local package correctly.
Applied to both production and dev Dockerfiles.
2026-03-28 22:35:53 -04:00
MayaTheShy
ed612f3e38 refactor: enhance message handling for dual-mode channel compatibility using Channels.match() 2026-03-26 16:19:42 -04:00
MayaTheShy
dcd9e22b6f refactor: enhance command processing for dual-mode channel compatibility 2026-03-26 16:19:38 -04:00
MayaTheShy
f1c8f08272 refactor: enhance status message handling for dual-mode channel compatibility 2026-03-26 16:19:33 -04:00
MayaTheShy
ffb6d679c0 refactor: enhance status message handling for dual-mode channel compatibility 2026-03-26 16:19:29 -04:00
MayaTheShy
ea90a860e9 refactor: enhance channel message handling with dual-mode support for legacy and target channels 2026-03-26 16:19:25 -04:00
MayaTheShy
bdf7a51675 refactor: replace hardcoded channel IDs with dynamic retrieval from platform.channels 2026-03-26 15:22:51 -04:00
MayaTheShy
c4b9509b5c refactor: replace hardcoded channel IDs with dynamic retrieval from platform.channels 2026-03-26 15:22:36 -04:00
MayaTheShy
92ea13a680 refactor: replace hardcoded status channel ID with dynamic retrieval from platform.channels 2026-03-26 15:22:31 -04:00
MayaTheShy
05cf7e98d9 refactor: replace hardcoded channel IDs with dynamic channel retrieval from platform.channels 2026-03-26 15:22:23 -04:00
MayaTheShy
9291b063d0 refactor: replace express server setup with platform server integration and streamline proxy endpoints 2026-03-26 15:19:53 -04:00
MayaTheShy
6f462c97e0 fix: add missing dependency for cc-platform server in package.json 2026-03-26 15:19:47 -04:00
MayaTheShy
9ff2ce7ff2 refactor: streamline configuration loading and channel setup in web bridge 2026-03-26 15:19:42 -04:00
MayaTheShy
fc1b23470e feat: add required platform specification in package configuration 2026-03-26 15:19:37 -04:00
MayaTheShy
cb44dd8d0f fix: correct requires format in apps.db and add missing entries
Opus Overview expects requires as a string (e.g. 'pocket'),
not a table. Fixed pocket apps and added turtle requirement
for turtle controller. Added pocketcontrol.lua entry.
2026-03-22 18:40:20 -04:00
MayaTheShy
1f41e1fa51 docs: add GPS alternative and pathfinding usage to quickstart guide 2026-03-22 18:24:27 -04:00
MayaTheShy
fa085339b8 feat: implement movement wrapping and pathfinding module for turtle 2026-03-22 18:16:06 -04:00
MayaTheShy
5ad01dfd1d feat: add web bridge configuration setup to installation process 2026-03-22 16:11:11 -04:00
MayaTheShy
b72826bc46 feat: implement dynamic configuration loading for server and WebSocket URLs 2026-03-22 16:07:55 -04:00
MayaTheShy
459664825c fix: update exclude patterns in package configuration for clarity 2026-03-22 16:07:52 -04:00
12 changed files with 449 additions and 152 deletions

View File

@@ -1,6 +1,34 @@
{ {
required = {
'platform',
},
title = "RemoteTurtle", 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.", 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/", 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
]],
} }

View File

@@ -64,6 +64,36 @@ You should see:
**GPS not working?** **GPS not working?**
- Set up 4 GPS host computers at high altitude - Set up 4 GPS host computers at high altitude
- Run `gps host X Y Z` on each (with their coordinates) - 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 ## Next Steps

View File

@@ -3,6 +3,7 @@
title = "Turtle Controller", title = "Turtle Controller",
category = "RemoteTurtle", category = "RemoteTurtle",
run = "turtle.lua", run = "turtle.lua",
requires = "turtle",
}, },
[ "rt_gps_host" ] = { [ "rt_gps_host" ] = {
title = "GPS Host", title = "GPS Host",
@@ -14,16 +15,22 @@
category = "RemoteTurtle", category = "RemoteTurtle",
run = "webbridge.lua", run = "webbridge.lua",
}, },
[ "rt_pocket_control" ] = {
title = "Pocket Control",
category = "RemoteTurtle",
run = "pocketcontrol.lua",
requires = "pocket",
},
[ "rt_pocket_remote" ] = { [ "rt_pocket_remote" ] = {
title = "Pocket Remote", title = "Pocket Remote",
category = "RemoteTurtle", category = "RemoteTurtle",
run = "pocketremote.lua", run = "pocketremote.lua",
requires = { pocket = true }, requires = "pocket",
}, },
[ "rt_pocket_gps" ] = { [ "rt_pocket_gps" ] = {
title = "Pocket GPS", title = "Pocket GPS",
category = "RemoteTurtle", category = "RemoteTurtle",
run = "pocketgps.lua", run = "pocketgps.lua",
requires = { pocket = true }, requires = "pocket",
}, },
} }

View File

@@ -2,10 +2,12 @@
-- Combines turtle control, GPS tracking, server management, and webbridge control -- Combines turtle control, GPS tracking, server management, and webbridge control
-- Communicates wirelessly with webbridge - NO direct HTTP calls -- Communicates wirelessly with webbridge - NO direct HTTP calls
local CHANNEL_SEND = 100 local Channels = require('platform.channels')
local CHANNEL_RECEIVE = 101
local STATUS_CHANNEL = 102 local CHANNEL_SEND = Channels.get('remoteturtle.command')
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
-- Find modem -- Find modem
local modem = peripheral.find("modem") local modem = peripheral.find("modem")
@@ -19,9 +21,12 @@ if pocket then
modem = peripheral.find("modem") modem = peripheral.find("modem")
end end
modem.open(CHANNEL_RECEIVE) local WebBridge = require('platform.webbridge')
modem.open(STATUS_CHANNEL) WebBridge.openChannels(modem, {
modem.open(POCKET_CHANNEL) 'remoteturtle.response',
'remoteturtle.status',
'remoteturtle.pocket',
})
local w, h = term.getSize() local w, h = term.getSize()
@@ -574,7 +579,9 @@ parallel.waitForAny(
while true do while true do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message") 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 if message.type == "status" then
-- Update turtle list -- Update turtle list
local found = false local found = false
@@ -590,7 +597,7 @@ parallel.waitForAny(
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime) addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
end end
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 -- Handle responses from webbridge
if message.type == "webbridge_status" then if message.type == "webbridge_status" then
webbridgeStatus = message.data webbridgeStatus = message.data

View File

@@ -1,7 +1,10 @@
-- Live GPS Tracker for Pocket Computer -- Live GPS Tracker for Pocket Computer
-- Shows your current location in real-time -- 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 -- Setup modem
local modem = peripheral.find("modem") local modem = peripheral.find("modem")
@@ -15,7 +18,7 @@ if pocket then
modem = peripheral.find("modem") modem = peripheral.find("modem")
end end
modem.open(STATUS_CHANNEL) WebBridge.openChannels(modem, { 'remoteturtle.status' })
local w, h = term.getSize() local w, h = term.getSize()
local myID = os.getComputerID() local myID = os.getComputerID()
@@ -228,7 +231,9 @@ local function main()
local replyChannel = param3 local replyChannel = param3
local message = param4 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) handleStatus(message)
end end

View File

@@ -1,9 +1,12 @@
-- Touch-Enabled Command Center for Pocket Computer (FIXED) -- Touch-Enabled Command Center for Pocket Computer (FIXED)
-- Monitor and control autonomous mining turtles -- Monitor and control autonomous mining turtles
local CHANNEL_SEND = 100 local Channels = require('platform.channels')
local CHANNEL_RECEIVE = 101 local WebBridge = require('platform.webbridge')
local STATUS_CHANNEL = 102
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") local modem = peripheral.find("modem")
if not modem then if not modem then
@@ -15,8 +18,10 @@ if pocket then
modem = peripheral.find("modem") modem = peripheral.find("modem")
end end
modem.open(CHANNEL_RECEIVE) WebBridge.openChannels(modem, {
modem.open(STATUS_CHANNEL) 'remoteturtle.response',
'remoteturtle.status',
})
local w, h = term.getSize() local w, h = term.getSize()
@@ -626,10 +631,12 @@ parallel.waitForAny(
end, end,
function() function()
-- Status receiver -- Status receiver
-- Uses Channels.match() for dual-mode safety: accepts status on
-- both legacy (102) and target (4212) channels during migration.
while true do while true do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message") 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 -- Update or add turtle
local found = false local found = false
for i, t in ipairs(turtles) do for i, t in ipairs(turtles) do
@@ -655,7 +662,7 @@ parallel.waitForAny(
end end
draw() 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 -- State change confirmation or other response
if message.type == "state_changed" and message.turtleID then if message.type == "state_changed" and message.turtleID then
for i, t in ipairs(turtles) do for i, t in ipairs(turtles) do

View File

@@ -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 FROM node:18-alpine
WORKDIR /app WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
# Copy package files # Copy package files
COPY package*.json ./ 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 # Install dependencies
RUN npm install --omit=dev RUN npm install --omit=dev

View File

@@ -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 FROM node:18-alpine
WORKDIR /app WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
# Install nodemon for hot reload # Install nodemon for hot reload
RUN npm install -g nodemon RUN npm install -g nodemon
# Copy package files # Copy package files
COPY package*.json ./ 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) # Install all dependencies (including dev)
RUN npm install RUN npm install

View File

@@ -19,6 +19,7 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cc-platform/server": "file:../../cc-platform-core/server",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^9.2.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",

View File

@@ -1,41 +1,27 @@
import express from 'express';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import cors from 'cors'; import { createRequire } from 'module';
import { createServer } from 'http';
import * as db from './database.js'; import * as db from './database.js';
import { Turtle } from './Turtle.js'; import { Turtle } from './Turtle.js';
import { TaskDispatcher } from './TaskDispatcher.js'; import { TaskDispatcher } from './TaskDispatcher.js';
import { WorldBlockCache } from './WorldBlockCache.js'; import { WorldBlockCache } from './WorldBlockCache.js';
const app = express(); const require = createRequire(import.meta.url);
const PORT = 3001; const {
createPlatformServer,
setupGracefulShutdown,
createProxyEndpoint,
} = require('@cc-platform/server');
app.use(cors()); // ========== Platform Server Setup ==========
app.use(express.json({ limit: '5mb' })); const { app, server, auth, start, port } = createPlatformServer({
serviceName: 'turtle-control',
// ========== API Key Authentication ========== port: 3001,
const API_KEY = process.env.API_KEY || ''; cors: true,
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);
}); });
const { requireAuth } = auth;
const API_KEY = process.env.API_KEY || '';
// Rewrite requests that arrive without /api prefix (from reverse proxy stripping it) // Rewrite requests that arrive without /api prefix (from reverse proxy stripping it)
app.use((req, res, next) => { app.use((req, res, next) => {
if (!req.path.startsWith('/api') && req.path !== '/' && req.path !== '/health') { if (!req.path.startsWith('/api') && req.path !== '/' && req.path !== '/health') {
@@ -215,19 +201,12 @@ function getBlockPosition(turtlePos, facing, direction) {
return pos; return pos;
} }
// Create HTTP server
const server = createServer(app);
// WebSocket server for web clients AND bridge connections // WebSocket server for web clients AND bridge connections
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
// Track bridge connections separately // Track bridge connections separately
const bridgeClients = new Set(); 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. * Push a command to the webbridge for a specific turtle.
* If a bridge is connected via WebSocket, send instantly. * 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 ========== // ========== Cross-Project Integration API ==========
// These endpoints allow the Inventory Manager system to query turtle state // 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) // Get all turtle summaries (for inventory dashboard sidebar widget)
app.get('/api/integration/turtle-summary', (req, res) => { app.get('/api/integration/turtle-summary', (req, res) => {
const summaries = []; const summaries = [];
@@ -1902,62 +1879,29 @@ app.get('/api/integration/turtle-summary', (req, res) => {
}); });
// Proxy to inventory server (locate items for turtle pickup) // Proxy to inventory server (locate items for turtle pickup)
app.get('/api/integration/inventory-locate', async (req, res) => { createProxyEndpoint(app, '/api/integration/inventory-locate', 'INVENTORY_SERVER_URL', '/api/integration/locate-item');
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}` });
}
});
// Proxy to inventory server (low stock alerts — turtles could auto-mine missing resources) // Proxy to inventory server (low stock alerts — turtles could auto-mine missing resources)
app.get('/api/integration/inventory-alerts', async (req, res) => { createProxyEndpoint(app, '/api/integration/inventory-alerts', 'INVENTORY_SERVER_URL', '/api/integration/low-stock');
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}` });
}
});
// Proxy to inventory server (storage space check — should turtles keep mining?) // Proxy to inventory server (storage space check — should turtles keep mining?)
app.get('/api/integration/storage-status', async (req, res) => { createProxyEndpoint(app, '/api/integration/storage-status', 'INVENTORY_SERVER_URL', '/api/integration/storage-status');
if (!INVENTORY_SERVER_URL) {
return res.json({ configured: false, message: 'INVENTORY_SERVER_URL not configured' }); // ========== Graceful Shutdown & Start ==========
}
try { setupGracefulShutdown({
const resp = await fetch(`${INVENTORY_SERVER_URL}/api/integration/storage-status`); serviceName: 'turtle-control',
const data = await resp.json(); cleanup: [
res.json({ configured: true, ...data }); () => taskDispatcher.stop(),
} catch (err) { () => db.closeDatabase(),
res.status(502).json({ configured: true, error: `Cannot reach inventory server: ${err.message}` }); ],
}
}); });
// Graceful shutdown start(() => {
process.on('SIGINT', () => {
console.log('\n🛑 Shutting down server...');
taskDispatcher.stop();
db.closeDatabase();
process.exit(0);
});
server.listen(PORT, () => {
console.log(`✅ Server ready!`);
console.log(`\nConfigured turtles to send updates to:`); console.log(`\nConfigured turtles to send updates to:`);
console.log(` http://localhost:${PORT}/api/turtle/update`); console.log(` http://localhost:${port}/api/turtle/update`);
if (INVENTORY_SERVER_URL) { if (process.env.INVENTORY_SERVER_URL) {
console.log(`📦 Inventory server integration: ${INVENTORY_SERVER_URL}`); console.log(`📦 Inventory server integration: ${process.env.INVENTORY_SERVER_URL}`);
} }
// Start task dispatcher after server is ready // Start task dispatcher after server is ready
taskDispatcher.start(); taskDispatcher.start();

View File

@@ -3,9 +3,11 @@
-- This script only handles: eval execution, status broadcasting, -- This script only handles: eval execution, status broadcasting,
-- GPS tracking, inventory/peripheral events. -- GPS tracking, inventory/peripheral events.
local CHANNEL_RECEIVE = 100 local Channels = require('platform.channels')
local CHANNEL_SEND = 101
local STATUS_CHANNEL = 102 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) -- State tracking (lightweight - server drives everything)
local state = { local state = {
@@ -23,11 +25,13 @@ local config = {
} }
-- Check for modem -- Check for modem
local modem = peripheral.find("modem") local WebBridge = require('platform.webbridge')
local modem, modemSide = WebBridge.findModem(true)
if not modem then if not modem then
error("No wireless modem found!") error("No wireless modem found!")
end 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("Server-Driven Turtle v5 (Pure Eval Protocol)")
print("ID: " .. os.getComputerID()) print("ID: " .. os.getComputerID())
@@ -203,7 +207,215 @@ end
syncHomeWithServer() syncHomeWithServer()
updateFuel() 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() broadcastStatus()
-- ========== Main Loop ========== -- ========== Main Loop ==========
@@ -234,9 +446,11 @@ parallel.waitForAny(
function() function()
-- Command processing (eval protocol) -- 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 while true do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message") 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) processMessage(channel, message)
end end
end end

View File

@@ -2,16 +2,43 @@
-- Connects to server via WebSocket for instant bidirectional communication. -- Connects to server via WebSocket for instant bidirectional communication.
-- Forwards turtle modem messages to server and server commands to turtles. -- Forwards turtle modem messages to server and server commands to turtles.
-- Falls back to HTTP polling if WebSocket is unavailable. -- 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 WebBridge = require('platform.webbridge')
local WS_URL = "ws://beta:4200/ws/bridge" local Channels = require('platform.channels')
local CHANNEL_RECEIVE = 101
local STATUS_CHANNEL = 102
local COMMAND_CHANNEL = 100
local POCKET_CHANNEL = 103
-- 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") local monitor = peripheral.find("monitor")
if not modem then if not modem then
@@ -23,9 +50,12 @@ if hasMonitor then
monitor.setTextScale(0.5) monitor.setTextScale(0.5)
end end
modem.open(CHANNEL_RECEIVE) -- Open channels via platform (respects dual-mode migration)
modem.open(STATUS_CHANNEL) WebBridge.openChannels(modem, {
modem.open(POCKET_CHANNEL) 'remoteturtle.response',
'remoteturtle.status',
'remoteturtle.pocket',
})
-- Track turtles and stats -- Track turtles and stats
local turtles = {} local turtles = {}
@@ -177,32 +207,24 @@ local function wsSend(data)
return false return false
end end
-- ========== HTTP Fallback Functions ========== -- ========== HTTP Helpers (via platform) ==========
local function httpPost(path, data) local function httpPost(path, data)
local success, result = pcall(function() local result = WebBridge.httpPost(SERVER_URL .. path, data)
local response = http.post(SERVER_URL .. path, textutils.serializeJSON(data), { ["Content-Type"] = "application/json" }) if result then
if response then local ok, parsed = pcall(textutils.unserialiseJSON, result)
local content = response.readAll() if ok then return parsed end
response.close() end
return textutils.unserializeJSON(content) return nil
end
return nil
end)
return success and result or nil
end end
local function httpGet(path) local function httpGet(path)
local success, result = pcall(function() local result = WebBridge.httpGet(SERVER_URL .. path)
local response = http.get(SERVER_URL .. path) if result then
if response then local ok, parsed = pcall(textutils.unserialiseJSON, result)
local content = response.readAll() if ok then return parsed end
response.close() end
return textutils.unserializeJSON(content) return nil
end
return nil
end)
return success and result or nil
end end
-- ========== Forward Turtle Modem Message to Server ========== -- ========== Forward Turtle Modem Message to Server ==========
@@ -469,11 +491,13 @@ parallel.waitForAny(
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message") local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
stats.messagesReceived = stats.messagesReceived + 1 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 if type(message) == "table" then
forwardToServer(message) forwardToServer(message)
end end
elseif channel == POCKET_CHANNEL then elseif Channels.match('remoteturtle.pocket', channel) then
if type(message) == "table" then if type(message) == "table" then
handlePocketMessage(message) handlePocketMessage(message)
end end