Files
remoteturtle/server/server.js

358 lines
10 KiB
JavaScript

import express from 'express';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import { createServer } from 'http';
const app = express();
const PORT = 3001;
const WS_PORT = 3002;
app.use(cors());
app.use(express.json());
// Store connected web clients and turtle data
const webClients = new Set();
const turtleData = new Map(); // turtleID -> turtle state
const worldBlocks = new Map(); // "x,y,z" -> {name, metadata, discoveredBy, timestamp}
const turtleHomes = new Map(); // turtleID -> {x, y, z} home position
const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc}
// Timeout for considering turtles offline (30 seconds)
const TURTLE_TIMEOUT = 30000;
// Broadcast to all web clients
function broadcastToClients(data) {
const message = JSON.stringify(data);
webClients.forEach((client) => {
if (client.readyState === 1) { // OPEN
client.send(message);
}
});
}
// Cleanup stale turtles periodically
setInterval(() => {
const now = Date.now();
let removedCount = 0;
for (const [turtleID, turtle] of turtleData.entries()) {
if (now - turtle.lastUpdate > TURTLE_TIMEOUT) {
console.log(`🔌 Turtle ${turtleID} timed out (last seen ${Math.floor((now - turtle.lastUpdate) / 1000)}s ago)`);
turtleData.delete(turtleID);
removedCount++;
// Notify web clients
broadcastToClients({
type: 'turtle_removed',
turtleID: turtleID
});
}
}
if (removedCount > 0) {
console.log(`🧹 Cleaned up ${removedCount} offline turtle(s)`);
}
}, 10000); // Check every 10 seconds
// Helper to store discovered blocks
function storeBlock(x, y, z, blockData, turtleID) {
const key = `${x},${y},${z}`;
worldBlocks.set(key, {
...blockData,
discoveredBy: turtleID,
timestamp: Date.now()
});
}
// Helper to calculate block position based on turtle position and facing
function getBlockPosition(turtlePos, facing, direction) {
if (!turtlePos) return null;
const pos = { ...turtlePos };
if (direction === 'up') {
pos.y += 1;
} else if (direction === 'down') {
pos.y -= 1;
} else if (direction === 'forward') {
// Calculate based on facing direction
if (facing === 0) pos.z -= 1; // North
else if (facing === 1) pos.x += 1; // East
else if (facing === 2) pos.z += 1; // South
else if (facing === 3) pos.x -= 1; // West
}
return pos;
}
// Create HTTP server
const server = createServer(app);
// WebSocket server for web clients
const wss = new WebSocketServer({ port: WS_PORT });
console.log(`🚀 Turtle Control Server starting...`);
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
console.log(`🔌 WebSocket Server: ws://localhost:${WS_PORT}`);
// WebSocket connection handler
wss.on('connection', (ws) => {
console.log('🌐 New web client connected');
webClients.add(ws);
// Send current turtle data and world blocks to new client
const blocks = [];
for (const [key, blockData] of worldBlocks.entries()) {
const [x, y, z] = key.split(',').map(Number);
blocks.push({ x, y, z, ...blockData });
}
ws.send(JSON.stringify({
type: 'initial_state',
turtles: Array.from(turtleData.values()),
blocks: blocks
}));
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
console.log('📨 Received from web client:', data);
// Handle commands from web client
if (data.type === 'command') {
// Store command for turtle to poll
const turtleID = data.turtleID;
if (turtleData.has(turtleID)) {
const turtle = turtleData.get(turtleID);
turtle.pendingCommands = turtle.pendingCommands || [];
turtle.pendingCommands.push({
command: data.command,
param: data.param,
timestamp: Date.now()
});
console.log(`✅ Command queued for turtle ${turtleID}: ${data.command}`, data.param ? `(param: ${data.param})` : '');
console.log(` Pending commands: ${turtle.pendingCommands.length}`);
} else {
console.log(`❌ Turtle ${turtleID} not found in turtleData`);
console.log(` Available turtles:`, Array.from(turtleData.keys()));
}
}
} catch (error) {
console.error('❌ Error processing web client message:', error);
}
});
ws.on('close', () => {
console.log('👋 Web client disconnected');
webClients.delete(ws);
});
ws.on('error', (error) => {
console.error('❌ WebSocket error:', error);
});
});
// REST API endpoint for turtles to update their status
app.post('/api/turtle/update', (req, res) => {
try {
const turtleUpdate = req.body;
const turtleID = turtleUpdate.turtleID;
console.log(`🐢 Status update from Turtle ${turtleID}`);
// Update turtle data
if (!turtleData.has(turtleID)) {
console.log(`✨ New turtle registered: ${turtleID}`);
}
// Merge with existing data, keeping server's home position if it exists
const existingData = turtleData.get(turtleID) || {};
const serverHome = turtleHomes.get(turtleID);
turtleData.set(turtleID, {
...existingData,
...turtleUpdate,
homePosition: serverHome || turtleUpdate.homePosition, // Server's home takes precedence
lastUpdate: Date.now()
});
// If turtle reports a home but server doesn't have one, store it
if (turtleUpdate.homePosition && !serverHome) {
turtleHomes.set(turtleID, turtleUpdate.homePosition);
console.log(` 📍 Stored home position for turtle ${turtleID}:`, turtleUpdate.homePosition);
}
// Store discovered blocks in world map
if (turtleUpdate.surroundings && turtleUpdate.position && turtleUpdate.facing !== undefined) {
for (const [direction, blockData] of Object.entries(turtleUpdate.surroundings)) {
const blockPos = getBlockPosition(turtleUpdate.position, turtleUpdate.facing, direction);
if (blockPos) {
storeBlock(blockPos.x, blockPos.y, blockPos.z, blockData, turtleID);
}
}
}
// Broadcast to web clients
broadcastToClients({
type: 'turtle_update',
turtle: turtleData.get(turtleID)
});
// Send back server-authoritative data
res.json({
success: true,
homePosition: turtleHomes.get(turtleID),
config: turtleConfig.get(turtleID) || {}
});
} catch (error) {
console.error('❌ Error processing turtle update:', error);
res.status(500).json({ error: error.message });
}
});
// Endpoint for turtles to poll for commands
app.get('/api/turtle/:id/commands', (req, res) => {
try {
const turtleID = parseInt(req.params.id);
if (turtleData.has(turtleID)) {
const turtle = turtleData.get(turtleID);
const commands = turtle.pendingCommands || [];
if (commands.length > 0) {
console.log(`📤 Sending ${commands.length} command(s) to turtle ${turtleID}`);
commands.forEach(cmd => console.log(` - ${cmd.command}`, cmd.param ? `(${cmd.param})` : ''));
}
// Clear pending commands
turtle.pendingCommands = [];
res.json({ commands });
} else {
res.json({ commands: [] });
}
} catch (error) {
console.error('❌ Error getting commands:', error);
res.status(500).json({ error: error.message });
}
});
// Get all turtles
app.get('/api/turtles', (req, res) => {
res.json({
turtles: Array.from(turtleData.values())
});
});
// Set home position for a turtle
app.post('/api/turtle/:id/home', (req, res) => {
try {
const turtleID = parseInt(req.params.id);
const { position } = req.body;
if (!position || !position.x || !position.y || !position.z) {
return res.status(400).json({ error: 'Invalid position' });
}
turtleHomes.set(turtleID, position);
console.log(`📍 Set home for turtle ${turtleID}:`, position);
// Update turtle data
if (turtleData.has(turtleID)) {
const turtle = turtleData.get(turtleID);
turtle.homePosition = position;
turtleData.set(turtleID, turtle);
// Broadcast update
broadcastToClients({
type: 'turtle_update',
turtle: turtle
});
}
res.json({ success: true, homePosition: position });
} catch (error) {
console.error('❌ Error setting home:', error);
res.status(500).json({ error: error.message });
}
});
// Get turtle home position
app.get('/api/turtle/:id/home', (req, res) => {
try {
const turtleID = parseInt(req.params.id);
const home = turtleHomes.get(turtleID);
res.json({
success: true,
homePosition: home || null
});
} catch (error) {
console.error('❌ Error getting home:', error);
res.status(500).json({ error: error.message });
}
});
// Get world blocks for map visualization
app.get('/api/world/blocks', (req, res) => {
const blocks = [];
for (const [key, blockData] of worldBlocks.entries()) {
const [x, y, z] = key.split(',').map(Number);
blocks.push({
x, y, z,
...blockData
});
}
res.json({ blocks });
});
// Send command to turtle
app.post('/api/turtle/:id/command', (req, res) => {
try {
const turtleID = parseInt(req.params.id);
const { command, param } = req.body;
if (turtleData.has(turtleID)) {
const turtle = turtleData.get(turtleID);
turtle.pendingCommands = turtle.pendingCommands || [];
turtle.pendingCommands.push({
command,
param,
timestamp: Date.now()
});
console.log(`📤 Command queued for turtle ${turtleID}:`, command);
res.json({ success: true });
} else {
res.status(404).json({ error: 'Turtle not found' });
}
} catch (error) {
console.error('❌ Error sending command:', error);
res.status(500).json({ error: error.message });
}
});
// Cleanup stale turtles (haven't updated in 30 seconds)
setInterval(() => {
const now = Date.now();
const timeout = 30000; // 30 seconds
for (const [turtleID, turtle] of turtleData.entries()) {
if (now - turtle.lastUpdate > timeout) {
console.log(`🗑️ Removing stale turtle: ${turtleID}`);
turtleData.delete(turtleID);
broadcastToClients({
type: 'turtle_disconnected',
turtleID
});
}
}
}, 10000); // Check every 10 seconds
server.listen(PORT, () => {
console.log(`✅ Server ready!`);
console.log(`\nConfigured turtles to send updates to:`);
console.log(` http://localhost:${PORT}/api/turtle/update`);
});