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",
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
]],
}

View File

@@ -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

View File

@@ -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",
},
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

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
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

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
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

View File

@@ -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",

View File

@@ -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();

View File

@@ -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

View File

@@ -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