// Autonomy owns a live user's control context, never an administrative identity. const fs = require('node:fs/promises'); const path = require('node:path'); const { spawn } = require('node:child_process'); const roverManager = require('../roverManager'); const { isVerified, isDeterred } = require('../verificationService'); const { isAdmin } = require('../roleService'); const { submitCommand, issueCommand, subscribeCommandRecording } = require('../commandService'); const { recordCommandRequest } = require('../commandService/recording'); const { compatible } = require('./capabilities'); const OPERATING = new Set(['drive', 'motors', 'servo', 'headlight', 'laser', 'horn', 'peripheral', 'song', 'raw']); const clamp = (value, min, max) => Math.max(min, Math.min(max, Number(value) || 0)); function canControl(socket, roverId) { return Boolean(socket?.connected && isVerified(socket) && (isAdmin(socket) || !isDeterred(socket)) && roverManager.rovers.get(roverId)?.ws && roverManager.canDrive(roverId, socket)); } function createDriving({ root, config, onChange, activity }) { const sessions = new Map(); let stopping = false; let watchdog; let unsubscribe; function sendState(session, event) { const input = session.worker.stdin; if (input.destroyed || !input.writable || input.writableLength > 256 * 1024) return; input.write(JSON.stringify(event) + '\n'); } function transmit(session, command) { if (session.status === 'stopping') return; if (!canControl(session.socket, session.roverId)) { stop(session.roverId, 'Control permission lost'); return; } if (!OPERATING.has(command?.type)) throw new Error('Unsupported model command'); const record = roverManager.rovers.get(session.roverId); if (command.type === 'drive') { command.driveDirect = { left: clamp(command.driveDirect?.left, -500, 500), right: clamp(command.driveDirect?.right, -500, 500) }; } if (command.type === 'motors') { command.motorPwm = { main: clamp(command.motorPwm?.main, -127, 127), side: clamp(command.motorPwm?.side, -127, 127), vacuum: clamp(command.motorPwm?.vacuum, 0, 127) }; } if (command.type === 'servo') { const servo = record.meta?.cameraServo; if (!servo?.enabled) return; command.servo.angle = clamp(command.servo?.angle, servo.minAngle ?? 0, servo.maxAngle ?? 180); } const { type, ...data } = command; recordCommandRequest(session.socket, { roverId: session.roverId, type, data }, (result) => { session.lastResult = result.error || result.reason || 'issued'; if (result.id) session.lastAction = { at: Date.now(), command }; }, (request, reply) => submitCommand(session.socket, request, reply), 'model'); } function stop(roverId, reason = 'Stopped by user') { const session = sessions.get(roverId); if (!session) return Promise.resolve(); if (session.done) return session.done; session.status = 'stopping'; session.stopReason = reason; // A stop must still reach the rover after turn permission is revoked. for (const command of [ { type: 'drive', driveDirect: { left: 0, right: 0 } }, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } }, { type: 'horn', horn: { action: 'stop' } }, ]) { try { issueCommand(roverId, command); } catch { /* Rover may already be offline. */ } } session.worker.kill('SIGTERM'); const timer = setTimeout(() => session.worker.kill('SIGKILL'), 2000); session.done = session.closed.then(() => { clearTimeout(timer); sessions.delete(roverId); activity('Controller stopped', { roverId, reason }); onChange(); }); onChange(); return session.done; } async function start(socket, roverId, modelId) { if (stopping || !canControl(socket, roverId)) throw new Error('You cannot control this rover'); if (!/^[0-9a-f-]{36}$/.test(modelId)) throw new Error('Invalid model'); const directory = path.join(root, 'models', modelId); const metadata = JSON.parse(await fs.readFile(path.join(directory, 'model.json'), 'utf8')); if (metadata.id !== modelId || !compatible(metadata, roverManager.rovers.get(roverId))) { throw new Error('Model is not compatible with this rover'); } if (stopping || !canControl(socket, roverId)) throw new Error('Control permission changed'); if (sessions.has(roverId)) throw new Error('Stop the current controller before starting another model'); const worker = spawn(config.training.python, [path.join(__dirname, 'workers/infer.py'), '--model', directory, '--url', `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId)}`, '--threads', String(config.driving.threads), '--threshold', String(config.driving.actionThreshold)], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } }); const session = { worker, socket, roverId, modelId, modelName: metadata.name, status: 'starting', startedAt: Date.now(), lastPredictionAt: null }; session.closed = new Promise((resolve) => worker.once('close', resolve)); sessions.set(roverId, session); let output = ''; let stderr = ''; worker.stdin.on('error', () => { stop(roverId, 'Worker input closed'); }); worker.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-1000); }); worker.on('error', (error) => { stop(roverId, error.message); }); worker.once('close', () => { stop(roverId, stderr || 'Worker exited'); }); worker.stdout.on('data', (chunk) => { if (session.status === 'stopping') return; output += chunk; if (output.length > 1024 * 1024) { stop(roverId, 'Worker output exceeded limit'); return; } let newline; while ((newline = output.indexOf('\n')) >= 0) { const line = output.slice(0, newline); output = output.slice(newline + 1); try { const message = JSON.parse(line); if (message.kind === 'error') throw new Error(message.message); if (message.kind !== 'prediction') continue; if (!Number.isFinite(message.frameAt) || Math.abs(Date.now() - message.frameAt) > config.driving.staleMs) throw new Error('Video is stale'); if (!Number.isFinite(message.sensorAt) || Math.abs(Date.now() - message.sensorAt) > config.driving.staleMs) throw new Error('Sensors are stale'); if (!canControl(socket, roverId)) throw new Error('Control permission lost'); session.status = 'running'; session.lastPredictionAt = Date.now(); session.frameAt = message.frameAt; session.sensorAt = message.sensorAt; session.latencyMs = message.latencyMs; if (!Array.isArray(message.commands) || message.commands.length > 64) throw new Error('Invalid action batch'); // Manual commands intentionally do not stop or pause this controller. for (const command of message.commands) transmit(session, command); } catch (error) { stop(roverId, error.message); break; } } }); const sensors = roverManager.rovers.get(roverId)?.lastSensor?.decoded; if (sensors) sendState(session, { kind: 'sensor', ts: Date.now(), data: sensors }); activity('Controller started', { roverId, model: metadata.name, socketId: socket.id }); onChange(); } function sensor(event) { const session = sessions.get(String(event.roverId)); if (session) sendState(session, { kind: 'sensor', ts: Date.now(), data: event.sensors }); } function attach() { roverManager.managerEvents.on('sensor', sensor); unsubscribe = subscribeCommandRecording((event) => { const session = sessions.get(String(event.roverId)); if (session && event.phase === 'dispatched') sendState(session, { kind: 'command', ts: event.ts, data: event.command }); }); watchdog = setInterval(() => { for (const session of sessions.values()) { if (!canControl(session.socket, session.roverId)) stop(session.roverId, 'Control permission lost'); else if (Date.now() - (session.lastPredictionAt || session.startedAt) > (session.lastPredictionAt ? config.driving.staleMs : 30000)) { stop(session.roverId, 'No fresh model predictions'); } } }, 250); } return { attach, start, stop, canControl, isActive: (id) => sessions.has(id), getState: (id) => { const session = sessions.get(id); if (!session) return null; const { worker, socket, closed, done, ...state } = session; return { ...state, startedBy: socket.data?.nickname || socket.id, socketId: socket.id }; }, shutdown: async () => { stopping = true; clearInterval(watchdog); unsubscribe?.(); roverManager.managerEvents.off('sensor', sensor); await Promise.all([...sessions.keys()].map((id) => stop(id, 'Service disabled'))); }, kill: () => { for (const session of sessions.values()) session.worker.kill('SIGKILL'); }, }; } module.exports = { createDriving, canControl };