mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
303 lines
12 KiB
JavaScript
303 lines
12 KiB
JavaScript
// Server-owned docking operations.
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const io = require('../../globals/io');
|
|
const logger = require('../../globals/logger').child('dockingService');
|
|
const states = new WeakMap();
|
|
const intents = new Set(['enterDocking', 'cancelDocking', 'undock']);
|
|
const ASSIST_SPEED = 60;
|
|
const SENSOR_TIMEOUT_MS = 1500;
|
|
const UNDOCK_TIMEOUT_MS = 4000;
|
|
const REVERSE_TIMEOUT_MS = 1000;
|
|
// Preserve the working OI preparation sequence; only backing away is sensor-driven.
|
|
const MODE_COMMAND_DELAY_MS = 100;
|
|
const FULL_MODE_SETTLE_MS = 300;
|
|
|
|
function stateFor(record) {
|
|
if (!states.has(record)) {
|
|
states.set(record, {
|
|
contact: null, charging: false, latched: false, assist: false,
|
|
operation: null, error: null, seenAt: 0, timer: null, contactDockPending: false,
|
|
});
|
|
}
|
|
return states.get(record);
|
|
}
|
|
|
|
function snapshot(record) {
|
|
const state = stateFor(record);
|
|
return {
|
|
phase: state.operation ? 'undocking' : state.contact ? (state.charging ? 'charging' : 'docked') : state.assist ? 'assisting' : 'idle',
|
|
movementLocked: Boolean(state.operation || state.latched || state.error || state.contact == null),
|
|
cameraLocked: state.assist,
|
|
error: state.error,
|
|
};
|
|
}
|
|
|
|
function publish(record) {
|
|
if (!record.lastSensor?.decoded) return;
|
|
record.lastSensor.decoded = { ...record.lastSensor.decoded, docking: snapshot(record) };
|
|
// State transitions use the same telemetry event, including failures when the
|
|
// physical sensor stream has stalled. Never manufacture a new sensor timestamp.
|
|
io.to(record.room).emit('sensorFrame', {
|
|
roverId: record.id, frame: record.lastSensor.raw, sensors: record.lastSensor.decoded,
|
|
overcurrentProtection: record.lastOvercurrentProtection || null,
|
|
});
|
|
}
|
|
|
|
function send(record, payload) {
|
|
return require('../commandService').issueCommand(record.id, payload, { dockingOwned: true });
|
|
}
|
|
function stop(record) {
|
|
// Forget the previous held intent so protection cannot replay it after unlock.
|
|
require('../overcurrentProtectionService').protectCommand(record.id, 'drive', { driveDirect: { left: 0, right: 0 } });
|
|
send(record, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
|
}
|
|
function stopAllMotion(record) {
|
|
stop(record);
|
|
const payload = { motorPwm: { main: 0, side: 0, vacuum: 0 } };
|
|
require('../overcurrentProtectionService').protectCommand(record.id, 'motors', payload);
|
|
send(record, { type: 'motors', ...payload });
|
|
}
|
|
function mode(record, opcode) {
|
|
send(record, { type: 'raw', raw: Buffer.from([opcode]).toString('base64') });
|
|
}
|
|
function camera(record, angle) {
|
|
if (record.meta?.cameraServo?.enabled) send(record, { type: 'servo', servo: { angle } });
|
|
}
|
|
function song(record, notes, slot = 0, duration = 10) {
|
|
send(record, { type: 'song', song: { slot, notes: notes.map((note) => ({ note, duration })) } });
|
|
}
|
|
function finishAssist(record, { playExitSong = true } = {}) {
|
|
const state = stateFor(record);
|
|
if (!state.assist) return;
|
|
state.assist = false;
|
|
camera(record, 0);
|
|
if (playExitSong) song(record, [83, 76]);
|
|
}
|
|
function clearOperation(state) {
|
|
clearTimeout(state.timer);
|
|
state.timer = null;
|
|
state.operation = null;
|
|
}
|
|
function fail(record, code, message) {
|
|
const state = stateFor(record);
|
|
clearOperation(state);
|
|
state.error = { code, message };
|
|
state.latched = true;
|
|
try { stop(record); } catch (error) {
|
|
logger.warn('Unable to stop docking operation', { roverId: record.id, error: error.message });
|
|
}
|
|
publish(record);
|
|
}
|
|
function armDeadline(record) {
|
|
const state = stateFor(record);
|
|
clearTimeout(state.timer);
|
|
if (!state.operation) return;
|
|
const deadline = Math.min(
|
|
state.seenAt + SENSOR_TIMEOUT_MS,
|
|
state.operation.startedAt + UNDOCK_TIMEOUT_MS,
|
|
state.operation.reversingAt ? state.operation.reversingAt + REVERSE_TIMEOUT_MS : Infinity,
|
|
);
|
|
state.timer = setTimeout(() => {
|
|
const stale = Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS;
|
|
fail(record, stale ? 'telemetryUnavailable' : 'undockTimeout', stale ? 'Rover telemetry stopped during undocking.' : 'Unable to leave the dock.');
|
|
}, Math.max(1, deadline - Date.now()));
|
|
state.timer.unref?.();
|
|
}
|
|
|
|
function handleIntent(record, type, payload, socket) {
|
|
const state = stateFor(record);
|
|
if (type === 'cancelDocking') {
|
|
if (state.operation) fail(record, 'undockCancelled', 'Undocking stopped.');
|
|
stop(record);
|
|
finishAssist(record);
|
|
publish(record);
|
|
return;
|
|
}
|
|
state.error = null;
|
|
state.contactDockPending = false;
|
|
require('../roverManager').stopDockGuard(record.id);
|
|
stop(record);
|
|
if (type === 'enterDocking') {
|
|
state.latched = false;
|
|
if (!state.assist) {
|
|
state.assist = true;
|
|
camera(record, record.meta?.cameraServo?.minAngle ?? -45);
|
|
song(record, [76, 83]);
|
|
}
|
|
} else {
|
|
finishAssist(record);
|
|
camera(record, 0);
|
|
state.operation = {
|
|
startedAt: Date.now(), socket, backoff: payload.backoff !== false, reversing: false,
|
|
stage: 'start', nextStepAt: Date.now() + MODE_COMMAND_DELAY_MS,
|
|
};
|
|
mode(record, 128);
|
|
armDeadline(record);
|
|
}
|
|
publish(record);
|
|
}
|
|
|
|
function processTelemetry(record, sensors) {
|
|
const state = stateFor(record);
|
|
if (!sensors?.chargingSources || !sensors?.oiMode) return snapshot(record);
|
|
const previousContact = state.contact;
|
|
state.seenAt = Date.now();
|
|
state.contact = Boolean(sensors.chargingSources.homeBase);
|
|
state.charging = [1, 2, 3, 4].includes(sensors.chargingState?.code);
|
|
if (previousContact == null) state.latched = state.contact;
|
|
try {
|
|
const operation = state.operation;
|
|
if (operation) {
|
|
const manager = require('../roverManager');
|
|
if (!operation.socket?.connected || !manager.canDrive(record.id, operation.socket)) {
|
|
fail(record, 'controlLost', 'Undocking stopped because control changed.');
|
|
} else if (Date.now() - operation.startedAt >= UNDOCK_TIMEOUT_MS
|
|
|| (operation.reversingAt && Date.now() - operation.reversingAt >= REVERSE_TIMEOUT_MS)) {
|
|
fail(record, 'undockTimeout', 'Unable to leave the dock.');
|
|
} else if (Date.now() < operation.nextStepAt) {
|
|
// Advance only on fresh telemetry, after the previous OI command settles.
|
|
} else if (operation.stage === 'start') {
|
|
mode(record, 143);
|
|
operation.stage = 'dock';
|
|
operation.nextStepAt = Date.now() + MODE_COMMAND_DELAY_MS;
|
|
} else if (operation.stage === 'dock') {
|
|
mode(record, 132);
|
|
operation.stage = 'full';
|
|
operation.nextStepAt = Date.now() + FULL_MODE_SETTLE_MS;
|
|
} else if (sensors.oiMode.label === 'full') {
|
|
if (!state.contact || !operation.backoff) {
|
|
stop(record);
|
|
clearOperation(state);
|
|
state.latched = state.contact;
|
|
} else if (!operation.reversing) {
|
|
operation.reversing = true;
|
|
operation.reversingAt = Date.now();
|
|
const speed = Math.max(1, Math.min(500, Number(record.meta?.maxWheelSpeed) || 500));
|
|
// Track the controller's own intent in existing protection so stalls
|
|
// still stop it and subsequent adjustments refer to this reverse only.
|
|
const drive = require('../overcurrentProtectionService').protectCommand(record.id, 'drive', {
|
|
driveDirect: { left: -speed, right: -speed },
|
|
}, { bypassed: require('../roleService').isAdmin(operation.socket) });
|
|
if (!drive.driveDirect.left && !drive.driveDirect.right) throw new Error('Undocking blocked by wheel protection');
|
|
send(record, { ...drive, type: 'drive' });
|
|
}
|
|
} else if (operation.reversing) {
|
|
fail(record, 'modeChanged', 'Undocking stopped because the Roomba mode changed.');
|
|
}
|
|
armDeadline(record);
|
|
} else if (state.contact) {
|
|
state.latched = true;
|
|
if (previousContact !== true) {
|
|
stopAllMotion(record);
|
|
state.contactDockPending = true;
|
|
if (state.assist) song(record, [84], 1, 6);
|
|
} else if (state.contactDockPending && !state.charging) {
|
|
const speeds = sensors.wheelSpeedsMmPerSecond;
|
|
// A stop request does not prove the wheels stopped on the contacts.
|
|
// Require a subsequent frame with contact and measured stationary wheels.
|
|
if (speeds?.left === 0 && speeds?.right === 0) {
|
|
mode(record, 143);
|
|
state.contactDockPending = false;
|
|
}
|
|
}
|
|
if (state.charging) {
|
|
state.contactDockPending = false;
|
|
// An immediate exit song would replace the contact beep on the Roomba.
|
|
finishAssist(record, { playExitSong: false });
|
|
}
|
|
} else {
|
|
state.contactDockPending = false;
|
|
if (previousContact === true && state.assist) song(record, [72], 1, 6);
|
|
}
|
|
} catch (error) {
|
|
fail(record, 'commandFailed', error.message);
|
|
}
|
|
return snapshot(record);
|
|
}
|
|
|
|
function shouldIgnoreDriveInput(record, payload) {
|
|
if (!stateFor(record).operation) return false;
|
|
if (payload.driveDirect) return true;
|
|
const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
|
|
return Boolean(raw && [137, 145].includes(raw[0]));
|
|
}
|
|
|
|
function protectCommand(record, payload, { dockingOwned = false } = {}) {
|
|
if (dockingOwned) return payload;
|
|
const state = stateFor(record);
|
|
// Raw OI drive packets must obey the same movement lock. Other raw commands,
|
|
// including mode changes, remain available and can interrupt the operation.
|
|
const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
|
|
if (state.operation && raw && [128, 131, 132, 133, 134, 135, 136, 143].includes(raw[0])) {
|
|
// Explicit mode commands remain available and take precedence over an
|
|
// automatic sequence, including while it is still waiting for full mode.
|
|
fail(record, 'modeChanged', 'Undocking stopped by a Roomba mode command.');
|
|
}
|
|
if (raw && [137, 145].includes(raw[0])) {
|
|
if (raw.length !== 5) throw new Error('Invalid raw drive command');
|
|
const first = raw.readInt16BE(1);
|
|
const second = raw.readInt16BE(3);
|
|
if (raw[0] === 137 && first !== 0 && state.assist && !snapshot(record).movementLocked) {
|
|
const limited = Buffer.from(raw);
|
|
limited.writeInt16BE(Math.max(-ASSIST_SPEED, Math.min(ASSIST_SPEED, first)), 1);
|
|
return { ...payload, raw: limited.toString('base64') };
|
|
}
|
|
if (raw[0] === 145) {
|
|
return protectCommand(record, { type: 'drive', driveDirect: { left: second, right: first } });
|
|
}
|
|
if (first === 0 && state.operation) fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
|
|
if (first !== 0 && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
|
|
}
|
|
// User drive input is discarded at submitCommand before reaching here.
|
|
// Internal safety stops still interrupt the controller through this path.
|
|
if (payload.driveDirect) {
|
|
const { left, right } = payload.driveDirect;
|
|
if (!Number(left) && !Number(right) && state.operation) {
|
|
fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
|
|
}
|
|
if ((Number(left) || Number(right)) && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
|
|
if (state.assist) {
|
|
const scale = Math.min(1, ASSIST_SPEED / Math.max(Math.abs(left), Math.abs(right), 1));
|
|
return { ...payload, driveDirect: { left: Math.round(left * scale), right: Math.round(right * scale) } };
|
|
}
|
|
}
|
|
if (payload.servo && state.assist) throw new Error('Camera locked during dock assist');
|
|
return payload;
|
|
}
|
|
|
|
function disconnect(record) {
|
|
const state = stateFor(record);
|
|
if (state.operation) fail(record, 'roverDisconnected', 'Rover disconnected during undocking.');
|
|
clearOperation(state);
|
|
state.seenAt = 0;
|
|
state.contact = null;
|
|
state.assist = false;
|
|
}
|
|
|
|
function canAdjustReverse(record) {
|
|
return Boolean(record && stateFor(record).operation?.reversing);
|
|
}
|
|
|
|
function issueIntent(record, type, payload, socket) {
|
|
const state = stateFor(record);
|
|
// Validate before mutating, so duplicate/rejected requests cannot disturb an
|
|
// operation that is already in progress.
|
|
if (type !== 'cancelDocking') {
|
|
if (state.operation) throw new Error('Undocking is already in progress');
|
|
if (Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS || state.contact == null) {
|
|
throw new Error('Waiting for current rover telemetry');
|
|
}
|
|
// Already at the requested destination; acknowledge without restarting assist.
|
|
if (type === 'enterDocking' && state.contact) return uuidv4();
|
|
}
|
|
const id = uuidv4();
|
|
try {
|
|
handleIntent(record, type, payload, socket);
|
|
} catch (error) {
|
|
fail(record, 'commandFailed', error.message);
|
|
throw error;
|
|
}
|
|
return id;
|
|
}
|
|
module.exports = { shouldIgnoreDriveInput, canAdjustReverse, snapshot, intents, issueIntent, processTelemetry, protectCommand, disconnect };
|