mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
209 lines
12 KiB
JavaScript
209 lines
12 KiB
JavaScript
// Autonomy owns a live user's control context, never an administrative identity.
|
|
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 { acquireModel } = require('./models');
|
|
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();
|
|
const recent = 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') {
|
|
const limit = Math.min(500, Number(record.meta?.maxWheelSpeed) || 500);
|
|
command.driveDirect = { left: Math.round(clamp(command.driveDirect?.left, -limit, limit)), right: Math.round(clamp(command.driveDirect?.right, -limit, limit)) };
|
|
}
|
|
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) { session.submissions.push({ command, result: 'Servo unavailable' }); 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';
|
|
session.submissions.push({ command, result: session.lastResult });
|
|
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);
|
|
recent.delete(roverId);
|
|
recent.set(roverId, { modelId: session.modelId, stopReason: reason, history: session.history });
|
|
if (recent.size > 20) recent.delete(recent.keys().next().value);
|
|
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');
|
|
const acquired = await acquireModel(root, modelId);
|
|
const { directory, metadata } = acquired;
|
|
try {
|
|
if (!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');
|
|
} catch (error) { await acquired.release(); throw error; }
|
|
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, checkpointId: metadata.id, modelName: modelId === 'latest' ? 'Latest' : modelId === 'previous' ? 'Previous' : metadata.name, status: 'starting', startedAt: Date.now(), lastPredictionAt: null, threshold: config.driving.actionThreshold, history: [] };
|
|
session.closed = new Promise((resolve) => worker.once('close', resolve));
|
|
session.closed.then(() => acquired.release()).catch((error) => activity('Checkpoint release failed', { error: error.message }));
|
|
sessions.set(roverId, session);
|
|
recent.delete(roverId);
|
|
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;
|
|
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
|
|
session.appliedThreshold = message.threshold;
|
|
session.wheelSpeeds = message.wheelSpeeds;
|
|
session.sensorPresent = message.sensorPresent;
|
|
session.submissions = [];
|
|
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
|
|
session.predictionStatus = message.wheelSpeeds?.some((value) => value !== 0) ? 'Sending wheel controls' : 'Predicting stopped wheels';
|
|
// Manual commands intentionally do not stop or pause this controller.
|
|
for (const command of message.commands) transmit(session, command);
|
|
// Bound report history independently of camera and UI frame rates.
|
|
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
|
|
session.history.push({ at: Date.now(), wheelSpeeds: session.wheelSpeeds,
|
|
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
|
|
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
|
|
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason }) => ({ slot, score, reason })),
|
|
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result,
|
|
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
|
|
if (session.history.length > 60) session.history.shift();
|
|
}
|
|
} 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,
|
|
setThreshold(socket, roverId, value) {
|
|
const session = sessions.get(roverId);
|
|
if (!session || session.status === 'stopping' || !canControl(socket, roverId)) throw new Error('You cannot adjust this controller');
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < .05 || value > .99) throw new Error('Threshold must be between 0.05 and 0.99');
|
|
session.threshold = value;
|
|
sendState(session, { kind: 'settings', ts: Date.now(), threshold: value });
|
|
},
|
|
getDiagnostics(id) {
|
|
const session = sessions.get(id);
|
|
return session ? { modelId: session.modelId, sensorPresent: session.sensorPresent, history: session.history } : recent.get(id) || null;
|
|
},
|
|
isActive: (id) => sessions.has(id),
|
|
getState: (id) => {
|
|
const session = sessions.get(id);
|
|
if (!session) return null;
|
|
const { worker, socket, closed, done, history, ...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 };
|