This commit is contained in:
legop3
2026-03-24 02:48:16 -04:00
parent e0167939d7
commit 3af21846c8
32 changed files with 1178 additions and 202 deletions
+633 -29
View File
@@ -21,8 +21,171 @@ const DOCK_GUARD_RETRY_MS = 10 * 1000;
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
const BACKOFF_MS = 500;
const BACKOFF_SPEED = 300;
const PRIVATE_BUTTON_HOLD_MS = 3000;
const PRIVATE_AUTO_CLOSE_IDLE_MS = 30 * 60 * 1000;
const PRIVATE_AUTO_CLOSE_TICK_MS = 30000;
const SAFETY_BACKOFF_MIN = -500;
const SAFETY_BACKOFF_MAX = 500;
const backoffTimers = new Map(); // roverId -> Timeout
const dockGuardStates = new Map(); // roverId -> guard state
const privateButtonStates = new Map(); // roverId -> { pressedSince:number|null, latched:boolean }
const privateNoUsersSince = new Map(); // roverId -> timestamp|null
const privateSafetyTimers = new Map(); // roverId -> Timeout
const privateSafetyStates = new Map(); // roverId -> state
const DEFAULT_PRIVATE_SAFETY = Object.freeze({
speedLimitEnabled: false,
speedLimitMaxWheelSpeed: 250,
hardOvercurrentEnabled: false,
overcurrentStopMs: 300,
hardBumpEnabled: false,
bumpBackoffSpeed: 250,
bumpBackoffMs: 350,
cliffEnabled: false,
cliffBackoffSpeed: 250,
cliffBackoffMs: 500,
triggerCooldownMs: 800,
});
function parsePrivateMeta(meta = {}) {
const raw = meta?.private;
if (raw === true) {
return { enabled: true, safety: { ...DEFAULT_PRIVATE_SAFETY } };
}
if (!raw || typeof raw !== 'object') {
return { enabled: false, safety: { ...DEFAULT_PRIVATE_SAFETY } };
}
const safety = normalizePrivateSafety(raw.safety || {});
return {
enabled: Boolean(raw.enabled),
safety,
};
}
function clampInt(value, min, max, fallback) {
const num = Number.parseInt(value, 10);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
}
function normalizePrivateSafety(raw = {}) {
const source = raw && typeof raw === 'object' ? raw : {};
return {
speedLimitEnabled: Boolean(source.speedLimitEnabled),
speedLimitMaxWheelSpeed: clampInt(
source.speedLimitMaxWheelSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed,
),
hardOvercurrentEnabled: Boolean(source.hardOvercurrentEnabled),
overcurrentStopMs: clampInt(
source.overcurrentStopMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.overcurrentStopMs,
),
hardBumpEnabled: Boolean(source.hardBumpEnabled),
bumpBackoffSpeed: clampInt(
source.bumpBackoffSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.bumpBackoffSpeed,
),
bumpBackoffMs: clampInt(
source.bumpBackoffMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.bumpBackoffMs,
),
cliffEnabled: Boolean(source.cliffEnabled),
cliffBackoffSpeed: clampInt(
source.cliffBackoffSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.cliffBackoffSpeed,
),
cliffBackoffMs: clampInt(
source.cliffBackoffMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.cliffBackoffMs,
),
triggerCooldownMs: clampInt(
source.triggerCooldownMs,
100,
10000,
DEFAULT_PRIVATE_SAFETY.triggerCooldownMs,
),
};
}
function isPrivateRecord(record) {
return Boolean(record?.private?.enabled);
}
function isPrivateOpen(record) {
if (!isPrivateRecord(record)) return true;
return Boolean(record?.privateOpen);
}
function getPrivateSafety(record) {
if (!record) return { ...DEFAULT_PRIVATE_SAFETY };
return normalizePrivateSafety(record.privateSafety || record.private?.safety || {});
}
function shouldApplyPrivateSafety(record, socket) {
if (!isPrivateRecord(record) || !isPrivateOpen(record)) return false;
if (isLockdownAdmin(socket)) return false;
return true;
}
function isRoverVisibleToSocket(record, socket) {
if (!record) return false;
if (!isPrivateRecord(record)) return true;
if (isPrivateOpen(record)) return true;
return isLockdownAdmin(socket);
}
function getControlDenialReason(record, socket, options = {}) {
const { allowUser = false } = options;
if (!record) {
return 'Unknown rover';
}
if (!allowUser && !isAdmin(socket)) {
return 'Only admins can request control';
}
if (record.locked && !isAdmin(socket)) {
return 'Rover locked';
}
const mode = getMode();
if (!allowUser && mode === MODES.ADMIN && !isAdmin(socket)) {
return 'Admins only';
}
if (!allowUser && mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return 'Server in lockdown';
}
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return 'Server in lockdown';
}
if (!isPrivateRecord(record)) {
return null;
}
if (!isPrivateOpen(record)) {
if (!isLockdownAdmin(socket)) {
return 'Private rover is closed';
}
return null;
}
if (isLockdownAdmin(socket)) {
return null;
}
const { isVerified } = require('./verificationService');
if (!isVerified(socket)) {
return 'Private rover requires verification';
}
return null;
}
function ensureRecord(id) {
if (!rovers.has(id)) {
@@ -41,6 +204,9 @@ function ensureRecord(id) {
room: `rover:${id}`,
lastSeen: Date.now(),
lastMovementAt: Date.now(),
private: { enabled: false },
privateOpen: true,
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
});
}
return rovers.get(id);
@@ -58,6 +224,17 @@ function upsertRover(meta, ws) {
record.meta = meta;
record.ws = ws;
record.lastSeen = Date.now();
const privateMeta = parsePrivateMeta(meta);
const wasPrivate = isPrivateRecord(record);
record.private = privateMeta;
record.privateSafety = normalizePrivateSafety(privateMeta.safety);
if (privateMeta.enabled) {
if (isNew || !wasPrivate) {
record.privateOpen = false;
}
} else {
record.privateOpen = true;
}
if (record.nightVisionState == null && meta?.nightVision?.enabled) {
const ledOn = Boolean(meta.nightVision.initialOn);
record.nightVisionState = {
@@ -68,7 +245,12 @@ function upsertRover(meta, ws) {
rovers.set(id, record);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
sock?.join(record.room);
if (!sock) return;
if (isRoverVisibleToSocket(record, sock)) {
sock.join(record.room);
} else {
sock.leave(record.room);
}
});
managerEvents.emit('rover', { roverId: id, action: 'upsert', record });
if (isNew) {
@@ -83,6 +265,11 @@ function removeRover(id) {
if (!record) return;
rovers.delete(id);
stopDockGuard(id);
privateButtonStates.delete(id);
privateNoUsersSince.delete(id);
privateSafetyStates.delete(id);
clearTimeout(privateSafetyTimers.get(id));
privateSafetyTimers.delete(id);
turnService.cleanupRover(id);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
@@ -93,6 +280,27 @@ function removeRover(id) {
publishEvent({ source: 'roverManager', type: 'rover.offline', payload: { roverId: id } });
}
function sendPrivateToggleTTS(roverId, open, reason) {
const { issueCommand } = require('./commandService');
let text = open ? 'Private rover is now open.' : 'Private rover is now closed.';
if (!open && reason === 'auto_idle') {
text = 'Private rover closed due to inactivity.';
} else if (reason === 'button_hold') {
text = open ? 'Private rover opened locally.' : 'Private rover closed locally.';
}
try {
issueCommand(roverId, {
type: 'tts',
tts: {
text,
speak: true,
},
});
} catch (err) {
logger.warn('Private toggle TTS failed', { roverId, reason, error: err.message });
}
}
function lockRover(id, locked, options = {}) {
const record = rovers.get(id);
if (!record) {
@@ -141,6 +349,69 @@ function lockRover(id, locked, options = {}) {
return record.locked;
}
function setPrivateOpen(id, open, options = {}) {
const record = rovers.get(id);
if (!record) {
throw new Error('Unknown rover');
}
if (!isPrivateRecord(record)) {
throw new Error('Rover is not private');
}
const nextOpen = Boolean(open);
if (record.privateOpen === nextOpen) {
return nextOpen;
}
record.privateOpen = nextOpen;
const reason = options.reason || 'manual';
const silent = Boolean(options.silent);
if (!nextOpen) {
privateNoUsersSince.delete(id);
privateSafetyStates.delete(id);
clearTimeout(privateSafetyTimers.get(id));
privateSafetyTimers.delete(id);
}
if (!silent) {
sendAlert({
color: ALERT_COLOR,
title: nextOpen ? 'Private Rover Opened' : 'Private Rover Closed',
message: nextOpen ? `${id} opened (${reason}).` : `${id} closed (${reason}).`,
});
}
if (options.tts !== false) {
sendPrivateToggleTTS(id, nextOpen, reason);
}
publishEvent({
source: 'roverManager',
type: nextOpen ? 'rover.privateOpened' : 'rover.privateClosed',
payload: { roverId: id, reason },
});
managerEvents.emit('private', { roverId: id, open: nextOpen, reason });
broadcastRoster();
return nextOpen;
}
function setPrivateSafety(id, patch = {}, options = {}) {
const record = rovers.get(id);
if (!record) {
throw new Error('Unknown rover');
}
if (!isPrivateRecord(record)) {
throw new Error('Rover is not private');
}
const current = getPrivateSafety(record);
const next = normalizePrivateSafety({ ...current, ...(patch || {}) });
record.privateSafety = next;
const reason = options.reason || 'manual';
publishEvent({
source: 'roverManager',
type: 'rover.privateSafetyUpdated',
payload: { roverId: id, reason, safety: next },
});
managerEvents.emit('privateSafety', { roverId: id, reason, safety: next });
broadcastRoster();
return next;
}
function getRoster() {
return Array.from(rovers.values()).map((record) => ({
id: record.id,
@@ -156,14 +427,51 @@ function getRoster() {
nightVision: record.meta?.nightVision
? { ...record.meta.nightVision, state: record.nightVisionState }
: record.meta?.nightVision,
locked: record.locked,
lockReason: record.lockReason,
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason:
record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
lastSeen: record.lastSeen,
private: isPrivateRecord(record)
? {
enabled: true,
open: isPrivateOpen(record),
safety: getPrivateSafety(record),
}
: {
enabled: false,
open: true,
safety: getPrivateSafety(record),
},
}));
}
function getRosterForSocket(socket) {
return getRoster()
.filter((entry) => {
const record = rovers.get(String(entry.id));
return isRoverVisibleToSocket(record, socket);
});
}
function syncSpectatorRooms() {
spectatorSockets.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
for (const record of rovers.values()) {
if (isRoverVisibleToSocket(record, socket)) {
socket.join(record.room);
} else {
socket.leave(record.room);
}
}
});
}
function broadcastRoster() {
io.emit('rovers', getRoster());
syncSpectatorRooms();
io.sockets.sockets.forEach((socket) => {
socket.emit('rovers', getRosterForSocket(socket));
});
}
function setNightVisionState(roverId, nightVisionOn) {
@@ -247,6 +555,192 @@ function computeBatteryDisplayPercent({ charge, full, warn, urgent, percent, cap
return null;
}
function getPrivateSafetyState(roverId) {
if (!privateSafetyStates.has(roverId)) {
privateSafetyStates.set(roverId, {
blockedUntil: 0,
lastOvercurrent: false,
lastBump: false,
lastCliff: false,
});
}
return privateSafetyStates.get(roverId);
}
function stopSafetyBackoffTimer(roverId) {
clearTimeout(privateSafetyTimers.get(roverId));
privateSafetyTimers.delete(roverId);
}
function triggerSafetyAction(record, mode, options = {}) {
if (!record) return;
const roverId = record.id;
const { issueCommand, setDriveCooldown } = require('./commandService');
const now = Date.now();
const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs);
const backoffMs = clampInt(options.backoffMs, 50, 5000, 0);
const backoffSpeed = clampInt(options.backoffSpeed, 0, 500, 0);
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
} catch (err) {
logger.warn('Private safety stop failed', { roverId, mode, error: err.message });
}
stopSafetyBackoffTimer(roverId);
if (backoffMs > 0 && backoffSpeed > 0) {
const speed = Math.max(SAFETY_BACKOFF_MIN, Math.min(SAFETY_BACKOFF_MAX, -Math.abs(backoffSpeed)));
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: speed, right: speed } });
} catch (err) {
logger.warn('Private safety backoff failed', { roverId, mode, error: err.message });
}
privateSafetyTimers.set(
roverId,
setTimeout(() => {
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
} catch (err) {
logger.warn('Private safety backoff stop failed', { roverId, mode, error: err.message });
}
privateSafetyTimers.delete(roverId);
}, backoffMs),
);
}
setDriveCooldown(roverId, Math.max(cooldownMs, backoffMs));
const state = getPrivateSafetyState(roverId);
state.blockedUntil = now + Math.max(cooldownMs, backoffMs);
sendAlert({
color: ALERT_COLOR,
title: 'Private Safety',
message: `${roverId} ${mode} safety triggered.`,
});
publishEvent({
source: 'roverManager',
type: 'rover.privateSafetyTriggered',
payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed },
});
}
function evaluatePrivateSafety(record, sensors) {
if (!record || !sensors) return;
const roverId = record.id;
const state = getPrivateSafetyState(roverId);
const overcurrent = Boolean(
sensors?.wheelOvercurrents?.leftWheel ||
sensors?.wheelOvercurrents?.rightWheel ||
sensors?.wheelOvercurrents?.mainBrush ||
sensors?.wheelOvercurrents?.sideBrush,
);
const bump = Boolean(sensors?.bumpsAndWheelDrops?.bumpLeft || sensors?.bumpsAndWheelDrops?.bumpRight);
const cliff = Boolean(
sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight,
);
const currentOver = overcurrent;
const currentBump = bump;
const currentCliff = cliff;
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
state.blockedUntil = 0;
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
return;
}
const safety = getPrivateSafety(record);
const now = Date.now();
if (now < Number(state.blockedUntil || 0)) {
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
return;
}
let triggered = false;
if (safety.hardOvercurrentEnabled && currentOver && !state.lastOvercurrent) {
triggerSafetyAction(record, 'overcurrent', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.overcurrentStopMs,
backoffSpeed: 0,
});
triggered = true;
} else if (safety.hardBumpEnabled && currentBump && !state.lastBump) {
triggerSafetyAction(record, 'bump', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.bumpBackoffMs,
backoffSpeed: safety.bumpBackoffSpeed,
});
triggered = true;
} else if (safety.cliffEnabled && currentCliff && !state.lastCliff) {
triggerSafetyAction(record, 'cliff', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.cliffBackoffMs,
backoffSpeed: safety.cliffBackoffSpeed,
});
triggered = true;
}
if (!triggered) {
state.blockedUntil = 0;
}
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
}
function applyPrivateDriveSafety(roverId, socket, driveDirect = null) {
const record = rovers.get(String(roverId));
if (!record || !driveDirect || typeof driveDirect !== 'object') {
return driveDirect;
}
if (!shouldApplyPrivateSafety(record, socket)) {
return driveDirect;
}
const safety = getPrivateSafety(record);
if (!safety.speedLimitEnabled) {
return driveDirect;
}
const limit = clampInt(
safety.speedLimitMaxWheelSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed,
);
const left = clampInt(driveDirect.left, -500, 500, 0);
const right = clampInt(driveDirect.right, -500, 500, 0);
return {
...driveDirect,
left: Math.max(-limit, Math.min(limit, left)),
right: Math.max(-limit, Math.min(limit, right)),
};
}
function handlePrivateButtonHold(record, sensors) {
if (!record || !isPrivateRecord(record)) return;
const buttons = sensors?.buttons || null;
const pressed = Boolean(buttons?.spot && buttons?.clean && buttons?.dock);
const roverId = record.id;
const now = Date.now();
const state = privateButtonStates.get(roverId) || { pressedSince: null, latched: false };
if (!pressed) {
if (state.pressedSince != null || state.latched) {
privateButtonStates.set(roverId, { pressedSince: null, latched: false });
}
return;
}
if (state.pressedSince == null) {
state.pressedSince = now;
}
if (!state.latched && now - state.pressedSince >= PRIVATE_BUTTON_HOLD_MS) {
const nextOpen = !isPrivateOpen(record);
try {
setPrivateOpen(roverId, nextOpen, { reason: 'button_hold', tts: true });
} catch (err) {
logger.warn('Private button toggle failed', { roverId, error: err.message });
}
state.latched = true;
}
privateButtonStates.set(roverId, state);
}
function handleSensorFrame(roverId, frame) {
const record = rovers.get(roverId);
if (!record) return;
@@ -268,6 +762,8 @@ function handleSensorFrame(roverId, frame) {
if (bumps?.bumpLeft || bumps?.bumpRight) {
record.lastBumpAt = Date.now();
}
handlePrivateButtonHold(record, decoded);
evaluatePrivateSafety(record, decoded);
io.to(record.room).volatile.emit('sensorFrame', {
roverId,
frame,
@@ -489,21 +985,9 @@ function removeSocket(socket) {
function requestControl(roverId, socket, options = {}) {
const { force = false, allowUser = false } = options;
const record = rovers.get(roverId);
if (!record) {
throw new Error('Unknown rover');
}
if (!allowUser && !isAdmin(socket)) {
throw new Error('Only admins can request control');
}
if (record.locked && !isAdmin(socket)) {
throw new Error('Rover locked');
}
const mode = getMode();
if (!allowUser && mode === MODES.ADMIN && !isAdmin(socket)) {
throw new Error('Admins only');
}
if (!allowUser && mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
const denied = getControlDenialReason(record, socket, { allowUser });
if (denied) {
throw new Error(denied);
}
record.drivers.add(socket.id);
if (!socketToRovers.has(socket.id)) {
@@ -545,10 +1029,13 @@ function isDriver(roverId, socket) {
}
function canDrive(roverId, socket) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
return isLockdownAdmin(socket);
const record = rovers.get(roverId);
if (!record) return false;
const denied = getControlDenialReason(record, socket, { allowUser: true });
if (denied) {
return false;
}
const mode = getMode();
if (isAdmin(socket)) {
return true;
}
@@ -594,6 +1081,14 @@ function hasOtherDrivers(record, socketId) {
}
function canSwitchRover(socket, targetRoverId) {
const target = rovers.get(targetRoverId);
if (!target) {
return { ok: false, message: 'Unknown rover' };
}
const denied = getControlDenialReason(target, socket, { allowUser: true });
if (denied) {
return { ok: false, message: denied };
}
const currentId = getPrimaryRoverForSocket(socket.id);
if (!currentId || currentId === targetRoverId) {
return { ok: true, currentId };
@@ -611,11 +1106,32 @@ function canSwitchRover(socket, targetRoverId) {
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
}
function canSeeRover(roverId, socket) {
const record = rovers.get(String(roverId));
return isRoverVisibleToSocket(record, socket);
}
function canRequestControl(roverId, socket, options = {}) {
const record = rovers.get(String(roverId));
const denied = getControlDenialReason(record, socket, options);
return { ok: !denied, reason: denied || null };
}
function canReplayRoverId(roverId) {
const record = rovers.get(String(roverId));
if (!record) return false;
if (!isPrivateRecord(record)) return true;
return isPrivateOpen(record);
}
module.exports = {
upsertRover,
removeRover,
lockRover,
setPrivateOpen,
setPrivateSafety,
getRoster,
getRosterForSocket,
broadcastRoster,
setNightVisionState,
handleSensorFrame,
@@ -630,6 +1146,10 @@ module.exports = {
managerEvents,
getRoversForSocket,
getPrimaryRoverForSocket,
canSeeRover,
canRequestControl,
applyPrivateDriveSafety,
canReplayRoverId,
};
roleEvents.on('change', ({ socket, role }) => {
@@ -641,7 +1161,8 @@ roleEvents.on('change', ({ socket, role }) => {
});
io.on('connection', (socket) => {
socket.emit('rovers', getRoster());
tickPrivateAutoClose();
socket.emit('rovers', getRosterForSocket(socket));
if (socket.data?.role === 'spectator') {
enableSpectator(socket);
}
@@ -658,7 +1179,10 @@ io.on('connection', (socket) => {
) {
throw new Error('Admins only');
}
const targetId = roverId || Array.from(rovers.keys())[0];
const fallbackTargetId = Array.from(rovers.keys()).find(
(id) => canRequestControl(id, socket, { allowUser: true }).ok,
);
const targetId = roverId || fallbackTargetId;
if (!targetId) {
throw new Error('No rovers available');
}
@@ -705,13 +1229,29 @@ io.on('connection', (socket) => {
}
function handleLockToggle({ roverId, locked } = {}, cb = () => {}) {
if (!isAdmin(socket)) {
const record = rovers.get(roverId);
if (!record) {
cb({ error: 'Unknown rover' });
return;
}
const isPrivate = isPrivateRecord(record);
if (isPrivate && !isLockdownAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
if (!isPrivate && !isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
lockRover(roverId, locked, { reason: 'manual' });
logger.info('Lock state changed', roverId, locked);
if (isPrivate) {
const open = !Boolean(locked);
setPrivateOpen(roverId, open, { reason: 'manual' });
logger.info('Private state changed', roverId, { open });
} else {
lockRover(roverId, locked, { reason: 'manual' });
logger.info('Lock state changed', roverId, locked);
}
cb({ success: true });
} catch (err) {
logger.warn('Lock change failed', roverId, err.message);
@@ -720,6 +1260,28 @@ io.on('connection', (socket) => {
}
}
function handlePrivateSafetySet({ roverId, safety } = {}, cb = () => {}) {
const record = rovers.get(roverId);
if (!record) {
cb({ error: 'Unknown rover' });
return;
}
if (!isPrivateRecord(record)) {
cb({ error: 'Rover is not private' });
return;
}
if (!isLockdownAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const next = setPrivateSafety(roverId, safety || {}, { reason: 'manual' });
cb({ success: true, safety: next });
} catch (err) {
cb({ error: err.message });
}
}
function handleSubscribeAll(_, cb = () => {}) {
if (socket.data?.role !== 'spectator') {
cb({ error: 'Spectator role required' });
@@ -731,7 +1293,11 @@ io.on('connection', (socket) => {
}
logger.info('Spectator subscribing to all rovers', socket.id);
for (const record of rovers.values()) {
socket.join(record.room);
if (isRoverVisibleToSocket(record, socket)) {
socket.join(record.room);
} else {
socket.leave(record.room);
}
}
cb({ success: true });
}
@@ -742,16 +1308,20 @@ io.on('connection', (socket) => {
socket.on('session:releaseControl', handleReleaseControl);
socket.on('lockRover', handleLockToggle);
socket.on('session:lockRover', handleLockToggle);
socket.on('privateSafety:set', handlePrivateSafetySet);
socket.on('session:privateSafety:set', handlePrivateSafetySet);
socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => {
logger.info('Socket disconnecting', socket.id);
removeSocket(socket);
tickPrivateAutoClose();
});
socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id);
removeSocket(socket);
tickPrivateAutoClose();
});
});
@@ -759,7 +1329,11 @@ function enableSpectator(socket) {
if (!socket?.id || spectatorSockets.has(socket.id)) return;
spectatorSockets.add(socket.id);
for (const record of rovers.values()) {
socket.join(record.room);
if (isRoverVisibleToSocket(record, socket)) {
socket.join(record.room);
} else {
socket.leave(record.room);
}
}
}
@@ -770,3 +1344,33 @@ function disableSpectator(socket) {
socket.leave(record.room);
}
}
function tickPrivateAutoClose() {
const now = Date.now();
const onlineCount = io.sockets.sockets.size;
for (const record of rovers.values()) {
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
privateNoUsersSince.delete(record.id);
continue;
}
if (onlineCount > 0) {
privateNoUsersSince.delete(record.id);
continue;
}
const since = privateNoUsersSince.get(record.id) || now;
if (!privateNoUsersSince.has(record.id)) {
privateNoUsersSince.set(record.id, since);
continue;
}
if (now - since >= PRIVATE_AUTO_CLOSE_IDLE_MS) {
try {
setPrivateOpen(record.id, false, { reason: 'auto_idle', tts: true });
} catch (err) {
logger.warn('Private auto-close failed', { roverId: record.id, error: err.message });
}
privateNoUsersSince.delete(record.id);
}
}
}
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);