Compare commits
23 Commits
633d162d81
...
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 | ||
|
|
b13905dade | ||
|
|
5a4af6c986 |
34
.package
Normal file
34
.package
Normal file
@@ -0,0 +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/", "^__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?**
|
**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
|
||||||
|
|
||||||
|
|||||||
36
etc/apps.db
Normal file
36
etc/apps.db
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
[ "rt_turtle_controller" ] = {
|
||||||
|
title = "Turtle Controller",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "turtle.lua",
|
||||||
|
requires = "turtle",
|
||||||
|
},
|
||||||
|
[ "rt_gps_host" ] = {
|
||||||
|
title = "GPS Host",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "gpshost.lua",
|
||||||
|
},
|
||||||
|
[ "rt_web_bridge" ] = {
|
||||||
|
title = "Web Bridge",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
[ "rt_pocket_gps" ] = {
|
||||||
|
title = "Pocket GPS",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "pocketgps.lua",
|
||||||
|
requires = "pocket",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
118
server/server.js
118
server/server.js
@@ -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();
|
||||||
|
|||||||
228
turtle.lua
228
turtle.lua
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user