mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
rmanager
This commit is contained in:
@@ -66,6 +66,11 @@
|
|||||||
- Split `server/src/services/turnService/index.js` by extracting constants, shared state helpers, and side-effect action helpers into `turnService/constants.js`, `turnService/state.js`, and `turnService/actions.js`.
|
- Split `server/src/services/turnService/index.js` by extracting constants, shared state helpers, and side-effect action helpers into `turnService/constants.js`, `turnService/state.js`, and `turnService/actions.js`.
|
||||||
- Split `server/src/services/sessionService/index.js` by extracting config/timing constants, sync-throttle state storage, and visibility filter helpers into `sessionService/constants.js`, `sessionService/state.js`, and `sessionService/filters.js`.
|
- Split `server/src/services/sessionService/index.js` by extracting config/timing constants, sync-throttle state storage, and visibility filter helpers into `sessionService/constants.js`, `sessionService/state.js`, and `sessionService/filters.js`.
|
||||||
- Began splitting `server/src/services/roverManager/index.js` by extracting immutable constants and shared state containers into `roverManager/constants.js` and `roverManager/state.js`.
|
- Began splitting `server/src/services/roverManager/index.js` by extracting immutable constants and shared state containers into `roverManager/constants.js` and `roverManager/state.js`.
|
||||||
|
- Continued `roverManager` split by extracting socket event wiring/handlers into `roverManager/socketHandlers.js` with dependency injection to keep existing behavior unchanged.
|
||||||
|
- Continued `roverManager` split by extracting numeric/private-safety normalization and battery math into `roverManager/mathUtils.js`.
|
||||||
|
- Continued `roverManager` split by extracting control lifecycle/switching logic into `roverManager/roverLifecycle.js`.
|
||||||
|
- Continued `roverManager` split by extracting sensor processing + private safety + dock guard logic into `roverManager/sensorPipeline.js`.
|
||||||
|
- Finished `roverManager` decomposition by extracting private access policy, roster lifecycle, and spectator/auto-close orchestration into `roverManager/privateAccess.js`, `roverManager/rosterLifecycle.js`, and `roverManager/spectatorAccess.js`; `roverManager/index.js` is now a thin composition layer.
|
||||||
- Hotfix: corrected `llmCommentaryService` prompt file path to `server/prompts/commentary_system.txt` after service folder move.
|
- Hotfix: corrected `llmCommentaryService` prompt file path to `server/prompts/commentary_system.txt` after service folder move.
|
||||||
- Hotfix: added `server/src/helpers/dataPaths.js` and rewired data-backed services to resolve canonical + legacy data-file locations safely after folderization (`adminReason`, `audioLevels`, `buttonBox`, `communityGoal`, `discordGuildStore`, `verification`, `replayEngineV2`).
|
- Hotfix: added `server/src/helpers/dataPaths.js` and rewired data-backed services to resolve canonical + legacy data-file locations safely after folderization (`adminReason`, `audioLevels`, `buttonBox`, `communityGoal`, `discordGuildStore`, `verification`, `replayEngineV2`).
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
|||||||
|
// rover Manager math utils
|
||||||
|
// Purpose: Centralizes numeric clamping and battery display calculations used by rover-manager orchestration.
|
||||||
|
// Scope: Keeps runtime behavior unchanged by extracting pure utility functions from the main service module.
|
||||||
|
const { DEFAULT_PRIVATE_SAFETY } = require('./constants');
|
||||||
|
|
||||||
|
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 computeBatteryDisplayPercent({ charge, full, warn, urgent, percent, capacity }) {
|
||||||
|
if (
|
||||||
|
charge != null &&
|
||||||
|
full != null &&
|
||||||
|
warn != null &&
|
||||||
|
urgent != null &&
|
||||||
|
full > warn &&
|
||||||
|
warn > urgent
|
||||||
|
) {
|
||||||
|
if (charge <= urgent) return 0;
|
||||||
|
if (charge <= warn) {
|
||||||
|
const t = (charge - urgent) / (warn - urgent);
|
||||||
|
return Math.round(Math.max(0, Math.min(1, t)) * 10);
|
||||||
|
}
|
||||||
|
if (charge >= full) return 100;
|
||||||
|
const t = (charge - warn) / (full - warn);
|
||||||
|
return Math.round((0.1 + Math.max(0, Math.min(1, t)) * 0.9) * 100);
|
||||||
|
}
|
||||||
|
if (percent != null && Number.isFinite(percent)) {
|
||||||
|
return Math.round(Math.max(0, Math.min(1, percent)) * 100);
|
||||||
|
}
|
||||||
|
if (charge != null && capacity != null && capacity > 0) {
|
||||||
|
const fallback = charge / capacity;
|
||||||
|
return Math.round(Math.max(0, Math.min(1, fallback)) * 100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBatteryState(record, sensors) {
|
||||||
|
if (!record) return null;
|
||||||
|
if (!sensors) return record.batteryState;
|
||||||
|
const config = record.meta?.battery || null;
|
||||||
|
const charge = sensors?.batteryChargeMah ?? null;
|
||||||
|
const capacity = sensors?.batteryCapacityMah ?? null;
|
||||||
|
const full = typeof config?.Full === 'number' ? config.Full : null;
|
||||||
|
const warn = typeof config?.Warn === 'number' ? config.Warn : null;
|
||||||
|
const urgent = typeof config?.Urgent === 'number' ? config.Urgent : null;
|
||||||
|
let percent = null;
|
||||||
|
if (charge != null && warn != null && full != null && full > warn) {
|
||||||
|
const span = full - warn;
|
||||||
|
percent = (charge - warn) / span;
|
||||||
|
} else if (charge != null && capacity != null && capacity > 0) {
|
||||||
|
percent = charge / capacity;
|
||||||
|
}
|
||||||
|
if (percent != null) {
|
||||||
|
percent = Math.max(0, Math.min(1, percent));
|
||||||
|
}
|
||||||
|
const percentDisplay = computeBatteryDisplayPercent({
|
||||||
|
charge,
|
||||||
|
full,
|
||||||
|
warn,
|
||||||
|
urgent,
|
||||||
|
percent,
|
||||||
|
capacity,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
charge,
|
||||||
|
capacity,
|
||||||
|
full,
|
||||||
|
warn,
|
||||||
|
urgent,
|
||||||
|
percent,
|
||||||
|
percentDisplay,
|
||||||
|
warnActive: Boolean(warn != null && charge != null && charge <= warn),
|
||||||
|
urgentActive: Boolean(urgent != null && charge != null && charge <= urgent),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
clampInt,
|
||||||
|
normalizePrivateSafety,
|
||||||
|
computeBatteryDisplayPercent,
|
||||||
|
computeBatteryState,
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// rover Manager private access
|
||||||
|
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
|
||||||
|
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
|
||||||
|
function createPrivateAccessPolicy(deps) {
|
||||||
|
const {
|
||||||
|
io,
|
||||||
|
turnService,
|
||||||
|
MODES,
|
||||||
|
getMode,
|
||||||
|
isAdmin,
|
||||||
|
isLockdownAdmin,
|
||||||
|
normalizePrivateSafety,
|
||||||
|
DEFAULT_PRIVATE_SAFETY,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
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 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)) return false;
|
||||||
|
if (isLockdownAdmin(socket)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldApplyPrivateSensorSafety(record) {
|
||||||
|
if (!isPrivateRecord(record)) return false;
|
||||||
|
const activeDrivers = turnService.getActiveDrivers();
|
||||||
|
const activeDriverId = activeDrivers?.[record.id];
|
||||||
|
if (!activeDriverId) return false;
|
||||||
|
const activeSocket = io.sockets.sockets.get(activeDriverId);
|
||||||
|
if (!activeSocket) return true;
|
||||||
|
return !isLockdownAdmin(activeSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function socketHasClosedPrivateAccess(socket, roverId) {
|
||||||
|
const list = Array.isArray(socket?.data?.privateClosedAccessRovers)
|
||||||
|
? socket.data.privateClosedAccessRovers
|
||||||
|
: [];
|
||||||
|
return list.some((id) => String(id) === String(roverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRoverVisibleToSocket(record, socket) {
|
||||||
|
if (!record) return false;
|
||||||
|
if (!isPrivateRecord(record)) return true;
|
||||||
|
if (isPrivateOpen(record)) return true;
|
||||||
|
if (isLockdownAdmin(socket)) return true;
|
||||||
|
return socketHasClosedPrivateAccess(socket, record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
const { isDeterred } = require('../verificationService');
|
||||||
|
if (!isAdmin(socket) && isDeterred(socket)) return 'Not authorized';
|
||||||
|
if (!isPrivateRecord(record)) return null;
|
||||||
|
if (!isPrivateOpen(record)) {
|
||||||
|
if (!isLockdownAdmin(socket)) {
|
||||||
|
if (socketHasClosedPrivateAccess(socket, record.id)) return null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsePrivateMeta,
|
||||||
|
isPrivateRecord,
|
||||||
|
isPrivateOpen,
|
||||||
|
getPrivateSafety,
|
||||||
|
shouldApplyPrivateSafety,
|
||||||
|
shouldApplyPrivateSensorSafety,
|
||||||
|
socketHasClosedPrivateAccess,
|
||||||
|
isRoverVisibleToSocket,
|
||||||
|
getControlDenialReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPrivateAccessPolicy,
|
||||||
|
};
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// rover Manager roster lifecycle
|
||||||
|
// Purpose: Manages rover record lifecycle, lock/private state transitions, and roster broadcasting.
|
||||||
|
// Scope: Keeps runtime behavior unchanged while isolating roster mutation and fanout concerns.
|
||||||
|
function createRosterLifecycle(deps) {
|
||||||
|
const {
|
||||||
|
io,
|
||||||
|
logger,
|
||||||
|
sendAlert,
|
||||||
|
publishEvent,
|
||||||
|
turnService,
|
||||||
|
ALERT_COLOR,
|
||||||
|
DEFAULT_PRIVATE_SAFETY,
|
||||||
|
rovers,
|
||||||
|
spectatorSockets,
|
||||||
|
managerEvents,
|
||||||
|
privateButtonStates,
|
||||||
|
privateNoUsersSince,
|
||||||
|
privateSafetyTimers,
|
||||||
|
privateSafetyStates,
|
||||||
|
parsePrivateMeta,
|
||||||
|
isPrivateRecord,
|
||||||
|
isPrivateOpen,
|
||||||
|
getPrivateSafety,
|
||||||
|
isRoverVisibleToSocket,
|
||||||
|
normalizePrivateSafety,
|
||||||
|
stopDockGuard,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function ensureRecord(id) {
|
||||||
|
if (!rovers.has(id)) {
|
||||||
|
rovers.set(id, {
|
||||||
|
id,
|
||||||
|
meta: null,
|
||||||
|
ws: null,
|
||||||
|
lastSensor: null,
|
||||||
|
docked: null,
|
||||||
|
lastBumpAt: null,
|
||||||
|
drivers: new Set(),
|
||||||
|
locked: false,
|
||||||
|
lockReason: null,
|
||||||
|
batteryState: null,
|
||||||
|
nightVisionState: null,
|
||||||
|
room: `rover:${id}`,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
lastMovementAt: Date.now(),
|
||||||
|
private: { enabled: false },
|
||||||
|
privateOpen: true,
|
||||||
|
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rovers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertRover(meta, ws) {
|
||||||
|
const id = meta.name || meta.id;
|
||||||
|
if (!meta.cameraServo) {
|
||||||
|
logger.info('Rover hello missing camera servo block', { id, keys: Object.keys(meta || {}) });
|
||||||
|
} else {
|
||||||
|
logger.info('Rover hello camera servo', { id, servo: meta.cameraServo });
|
||||||
|
}
|
||||||
|
const isNew = !rovers.has(id);
|
||||||
|
const record = ensureRecord(id);
|
||||||
|
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 = {
|
||||||
|
nightVisionOn: !ledOn,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
rovers.set(id, record);
|
||||||
|
spectatorSockets.forEach((socketId) => {
|
||||||
|
const sock = io.sockets.sockets.get(socketId);
|
||||||
|
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) publishEvent({ source: 'roverManager', type: 'rover.online', payload: { roverId: id } });
|
||||||
|
broadcastRoster();
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRover(id) {
|
||||||
|
const record = rovers.get(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);
|
||||||
|
sock?.leave(record.room);
|
||||||
|
});
|
||||||
|
broadcastRoster();
|
||||||
|
managerEvents.emit('rover', { roverId: id, action: 'removed' });
|
||||||
|
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) throw new Error('Unknown rover');
|
||||||
|
const wasAllUnlocked = Array.from(rovers.values()).every((entry) => !entry.locked);
|
||||||
|
const reason = locked ? options.reason || 'manual' : null;
|
||||||
|
const silent = Boolean(options.silent);
|
||||||
|
if (locked) {
|
||||||
|
record.locked = true;
|
||||||
|
record.lockReason = reason;
|
||||||
|
if (!silent) {
|
||||||
|
sendAlert({
|
||||||
|
color: ALERT_COLOR,
|
||||||
|
title: 'Rover Locked',
|
||||||
|
message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
publishEvent({ source: 'roverManager', type: 'rover.locked', payload: { roverId: id, reason: record.lockReason } });
|
||||||
|
} else {
|
||||||
|
record.locked = false;
|
||||||
|
record.lockReason = null;
|
||||||
|
if (!silent) sendAlert({ color: ALERT_COLOR, title: 'Rover Unlocked', message: `${id} unlocked.` });
|
||||||
|
publishEvent({ source: 'roverManager', type: 'rover.unlocked', payload: { roverId: id } });
|
||||||
|
const isAllUnlocked = Array.from(rovers.values()).every((entry) => !entry.locked);
|
||||||
|
if (!wasAllUnlocked && isAllUnlocked) {
|
||||||
|
publishEvent({ source: 'roverManager', type: 'rovers.allUnlocked', payload: { roverId: id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcastRoster();
|
||||||
|
managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason });
|
||||||
|
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,
|
||||||
|
name: record.meta?.name || record.id,
|
||||||
|
color: record.meta?.color || null,
|
||||||
|
battery: record.meta?.battery,
|
||||||
|
batteryState: record.batteryState,
|
||||||
|
maxWheelSpeed: record.meta?.maxWheelSpeed,
|
||||||
|
media: record.meta?.media,
|
||||||
|
cameraServo: record.meta?.cameraServo,
|
||||||
|
audio: record.meta?.audio,
|
||||||
|
horn: record.meta?.horn,
|
||||||
|
nightVision: record.meta?.nightVision
|
||||||
|
? { ...record.meta.nightVision, state: record.nightVisionState }
|
||||||
|
: record.meta?.nightVision,
|
||||||
|
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() {
|
||||||
|
syncSpectatorRooms();
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
socket.emit('rovers', getRosterForSocket(socket));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNightVisionState(roverId, nightVisionOn) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return;
|
||||||
|
if (typeof nightVisionOn !== 'boolean') return;
|
||||||
|
record.nightVisionState = {
|
||||||
|
nightVisionOn,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
broadcastRoster();
|
||||||
|
managerEvents.emit('rover', { roverId, action: 'nightVision', record });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = deps.getControlDenialReason(record, socket, options);
|
||||||
|
return { ok: !denied, reason: denied || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureRecord,
|
||||||
|
upsertRover,
|
||||||
|
removeRover,
|
||||||
|
lockRover,
|
||||||
|
setPrivateOpen,
|
||||||
|
setPrivateSafety,
|
||||||
|
getRoster,
|
||||||
|
getRosterForSocket,
|
||||||
|
syncSpectatorRooms,
|
||||||
|
broadcastRoster,
|
||||||
|
setNightVisionState,
|
||||||
|
canSeeRover,
|
||||||
|
canRequestControl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRosterLifecycle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// rover Manager lifecycle
|
||||||
|
// Purpose: Owns rover control membership and switching decisions for sockets.
|
||||||
|
// Scope: Keeps runtime behavior unchanged by reusing rover-manager state maps and injected policy helpers.
|
||||||
|
function createRoverLifecycle(deps) {
|
||||||
|
const {
|
||||||
|
rovers,
|
||||||
|
socketToRovers,
|
||||||
|
managerEvents,
|
||||||
|
turnService,
|
||||||
|
isAdmin,
|
||||||
|
sendAlert,
|
||||||
|
ALERT_COLOR,
|
||||||
|
getMode,
|
||||||
|
getControlDenialReason,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function removeSocket(socket, disableSpectator) {
|
||||||
|
const joined = socketToRovers.get(socket.id);
|
||||||
|
if (!joined) {
|
||||||
|
disableSpectator(socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const roverId of joined) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (record) {
|
||||||
|
record.drivers.delete(socket.id);
|
||||||
|
}
|
||||||
|
turnService.driverRemoved(roverId, socket.id);
|
||||||
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
|
||||||
|
}
|
||||||
|
socketToRovers.delete(socket.id);
|
||||||
|
disableSpectator(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestControl(roverId, socket, options = {}) {
|
||||||
|
const { force = false, allowUser = false } = options;
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
const denied = getControlDenialReason(record, socket, { allowUser });
|
||||||
|
if (denied) throw new Error(denied);
|
||||||
|
record.drivers.add(socket.id);
|
||||||
|
if (!socketToRovers.has(socket.id)) {
|
||||||
|
socketToRovers.set(socket.id, new Set());
|
||||||
|
}
|
||||||
|
socketToRovers.get(socket.id).add(roverId);
|
||||||
|
socket.join(record.room);
|
||||||
|
turnService.driverAdded(roverId, socket.id, force && isAdmin(socket));
|
||||||
|
socket.emit('controlGranted', { roverId });
|
||||||
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
|
||||||
|
sendAlert({
|
||||||
|
color: ALERT_COLOR,
|
||||||
|
title: 'Control Granted',
|
||||||
|
message: `${socket.id} now driving ${roverId}`,
|
||||||
|
});
|
||||||
|
return { roverId, room: record.room };
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseControl(roverId, socket) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return;
|
||||||
|
record.drivers.delete(socket.id);
|
||||||
|
const joined = socketToRovers.get(socket.id);
|
||||||
|
if (joined) {
|
||||||
|
joined.delete(roverId);
|
||||||
|
if (joined.size === 0) socketToRovers.delete(socket.id);
|
||||||
|
}
|
||||||
|
socket.leave(record.room);
|
||||||
|
turnService.driverRemoved(roverId, socket.id);
|
||||||
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDriver(roverId, socket) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return false;
|
||||||
|
return record.drivers.has(socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDrive(roverId, 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;
|
||||||
|
if (!socket || !isDriver(roverId, socket)) return false;
|
||||||
|
return turnService.canDrive(roverId, socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoversForSocket(socketId) {
|
||||||
|
const joined = socketToRovers.get(socketId);
|
||||||
|
if (!joined || joined.size === 0) return [];
|
||||||
|
return Array.from(joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryRoverForSocket(socketId) {
|
||||||
|
const joined = socketToRovers.get(socketId);
|
||||||
|
if (!joined || joined.size === 0) return null;
|
||||||
|
const iterator = joined.values();
|
||||||
|
const first = iterator.next();
|
||||||
|
return first.done ? null : first.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDockedAndCharging(record) {
|
||||||
|
const sensors = record?.lastSensor?.decoded || record?.lastSensor?.sensors || null;
|
||||||
|
if (!sensors) return false;
|
||||||
|
const docked = Boolean(sensors.chargingSources?.homeBase);
|
||||||
|
const code = sensors.chargingState?.code;
|
||||||
|
const charging = code === 2 || code === 3 || code === 4;
|
||||||
|
return docked && charging;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOtherDrivers(record, socketId) {
|
||||||
|
if (!record) return false;
|
||||||
|
for (const driver of record.drivers) {
|
||||||
|
if (driver !== socketId) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
const currentRecord = rovers.get(currentId);
|
||||||
|
if (!currentRecord) return { ok: true, currentId };
|
||||||
|
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
|
||||||
|
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
|
||||||
|
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReplayRoverId(roverId, isPrivateRecord, isPrivateOpen) {
|
||||||
|
const record = rovers.get(String(roverId));
|
||||||
|
if (!record) return false;
|
||||||
|
if (!isPrivateRecord(record)) return true;
|
||||||
|
return isPrivateOpen(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeSocket,
|
||||||
|
requestControl,
|
||||||
|
releaseControl,
|
||||||
|
isDriver,
|
||||||
|
canDrive,
|
||||||
|
getRoversForSocket,
|
||||||
|
getPrimaryRoverForSocket,
|
||||||
|
canSwitchRover,
|
||||||
|
canReplayRoverId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRoverLifecycle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// rover Manager sensor pipeline
|
||||||
|
// Purpose: Handles sensor-frame processing, private safety triggers, and dock-guard responses.
|
||||||
|
// Scope: Keeps runtime behavior unchanged while isolating stateful sensor/dock logic from service orchestration.
|
||||||
|
function createSensorPipeline(deps) {
|
||||||
|
const {
|
||||||
|
io,
|
||||||
|
logger,
|
||||||
|
rovers,
|
||||||
|
managerEvents,
|
||||||
|
dockGuardStates,
|
||||||
|
backoffTimers,
|
||||||
|
privateButtonStates,
|
||||||
|
privateSafetyTimers,
|
||||||
|
privateSafetyStates,
|
||||||
|
parseSensorFrame,
|
||||||
|
computeBatteryState,
|
||||||
|
clampInt,
|
||||||
|
DEFAULT_PRIVATE_SAFETY,
|
||||||
|
SAFETY_BACKOFF_MIN,
|
||||||
|
SAFETY_BACKOFF_MAX,
|
||||||
|
PRIVATE_BUTTON_HOLD_MS,
|
||||||
|
IDLE_UNDOCKED_MS,
|
||||||
|
PASSIVE_UNDOCKED_MS,
|
||||||
|
DOCK_GUARD_RETRY_MS,
|
||||||
|
DOCK_GUARD_WINDOW_MS,
|
||||||
|
DOCK_COMMAND_BASE64,
|
||||||
|
BACKOFF_MS,
|
||||||
|
BACKOFF_SPEED,
|
||||||
|
ALERT_COLOR,
|
||||||
|
sendAlert,
|
||||||
|
publishEvent,
|
||||||
|
isPrivateRecord,
|
||||||
|
isPrivateOpen,
|
||||||
|
getPrivateSafety,
|
||||||
|
setPrivateOpen,
|
||||||
|
shouldApplyPrivateSafety,
|
||||||
|
shouldApplyPrivateSensorSafety,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
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 (!shouldApplyPrivateSensorSafety(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 updateMovement(record, sensors) {
|
||||||
|
if (!record || !sensors) return;
|
||||||
|
const distance = Math.abs(sensors.distanceMm ?? 0);
|
||||||
|
const angle = Math.abs(sensors.angleDeg ?? 0);
|
||||||
|
const requested = Math.abs(sensors.requestedVelocity ?? 0);
|
||||||
|
const requestedLeft = Math.abs(sensors.requestedLeftVelocity ?? 0);
|
||||||
|
const requestedRight = Math.abs(sensors.requestedRightVelocity ?? 0);
|
||||||
|
const moving = distance > 0 || angle > 0 || requested > 0 || requestedLeft > 0 || requestedRight > 0;
|
||||||
|
if (moving) record.lastMovementAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDockGuardState(roverId) {
|
||||||
|
if (!dockGuardStates.has(roverId)) {
|
||||||
|
dockGuardStates.set(roverId, {
|
||||||
|
idleUndockedSince: null,
|
||||||
|
passiveUndockedSince: null,
|
||||||
|
active: false,
|
||||||
|
reason: null,
|
||||||
|
startedAt: null,
|
||||||
|
timer: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dockGuardStates.get(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDockGuard(roverId) {
|
||||||
|
const state = dockGuardStates.get(roverId);
|
||||||
|
if (!state) return;
|
||||||
|
if (state.timer) clearInterval(state.timer);
|
||||||
|
state.active = false;
|
||||||
|
state.reason = null;
|
||||||
|
state.startedAt = null;
|
||||||
|
state.timer = null;
|
||||||
|
state.idleUndockedSince = null;
|
||||||
|
state.passiveUndockedSince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptDockGuard(roverId) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) {
|
||||||
|
stopDockGuard(roverId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { issueCommand } = require('../commandService');
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, { type: 'sensorStream', sensorStream: { enable: true } });
|
||||||
|
issueCommand(roverId, { type: 'raw', raw: DOCK_COMMAND_BASE64 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock guard command failed', roverId, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDockGuard(record, reason, idleMs) {
|
||||||
|
if (!record) return;
|
||||||
|
const state = getDockGuardState(record.id);
|
||||||
|
if (state.active) return;
|
||||||
|
state.active = true;
|
||||||
|
state.reason = reason;
|
||||||
|
state.startedAt = Date.now();
|
||||||
|
const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked';
|
||||||
|
sendAlert({
|
||||||
|
color: ALERT_COLOR,
|
||||||
|
title: 'Dock Guard Triggered',
|
||||||
|
message: `${record.id} ${reasonText}. Seeking dock and restarting sensors until movement.`,
|
||||||
|
});
|
||||||
|
publishEvent({
|
||||||
|
source: 'roverManager',
|
||||||
|
type: 'rover.dockGuard',
|
||||||
|
payload: { roverId: record.id, reason, reasonText, idleMs },
|
||||||
|
});
|
||||||
|
attemptDockGuard(record.id);
|
||||||
|
state.timer = setInterval(() => attemptDockGuard(record.id), DOCK_GUARD_RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateDockGuard(record, sensors) {
|
||||||
|
if (!record || !sensors) return;
|
||||||
|
const state = getDockGuardState(record.id);
|
||||||
|
const now = Date.now();
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
|
const oiMode = sensors?.oiMode?.label || null;
|
||||||
|
const idleMs = record.lastMovementAt ? now - record.lastMovementAt : 0;
|
||||||
|
const isIdle = idleMs >= 1000;
|
||||||
|
if (docked || record.drivers.size > 0 || !isIdle) state.idleUndockedSince = null;
|
||||||
|
else if (!state.idleUndockedSince) state.idleUndockedSince = now;
|
||||||
|
if (docked || oiMode !== 'passive' || !isIdle) state.passiveUndockedSince = null;
|
||||||
|
else if (!state.passiveUndockedSince) state.passiveUndockedSince = now;
|
||||||
|
if (state.active) {
|
||||||
|
const shouldStop = docked || !isIdle || (state.reason === 'idle' && record.drivers.size > 0) || (state.reason === 'passive' && oiMode !== 'passive');
|
||||||
|
if (shouldStop) stopDockGuard(record.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idleReady = state.idleUndockedSince && now - state.idleUndockedSince >= IDLE_UNDOCKED_MS;
|
||||||
|
const passiveReady = state.passiveUndockedSince && now - state.passiveUndockedSince >= PASSIVE_UNDOCKED_MS;
|
||||||
|
if (passiveReady) startDockGuard(record, 'passive', idleMs);
|
||||||
|
else if (idleReady) startDockGuard(record, 'idle', idleMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeRover(roverId, direction = 'backward') {
|
||||||
|
if (!roverId) return;
|
||||||
|
const { issueCommand } = require('../commandService');
|
||||||
|
clearTimeout(backoffTimers.get(roverId));
|
||||||
|
const speed = direction === 'forward' ? BACKOFF_SPEED : -BACKOFF_SPEED;
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, { type: 'drive', driveDirect: { left: speed, right: speed } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock protection nudge failed', roverId, err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backoffTimers.set(
|
||||||
|
roverId,
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock protection backoff stop failed', roverId, err.message);
|
||||||
|
}
|
||||||
|
}, BACKOFF_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIdleUndock(undockedRecord) {
|
||||||
|
if (!undockedRecord || undockedRecord.drivers.size > 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const { getRecentDriveActivity, setDriveCooldown, issueCommand } = require('../commandService');
|
||||||
|
const candidates = getRecentDriveActivity(DOCK_GUARD_WINDOW_MS, { excludeAdmins: true }).filter(
|
||||||
|
(candidate) => candidate.roverId !== undockedRecord.id,
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
const candidatesWithBump = candidates.filter((candidate) => {
|
||||||
|
const record = rovers.get(candidate.roverId);
|
||||||
|
return record?.lastBumpAt && now - record.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
|
||||||
|
});
|
||||||
|
const pool = candidatesWithBump.length > 0 ? candidatesWithBump : candidates;
|
||||||
|
pool.sort((a, b) => b.ts - a.ts);
|
||||||
|
const suspect = pool[0];
|
||||||
|
if (!suspect) return;
|
||||||
|
const suspectRecord = rovers.get(suspect.roverId);
|
||||||
|
if (!suspectRecord) return;
|
||||||
|
const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
|
||||||
|
sendAlert({
|
||||||
|
color: ALERT_COLOR,
|
||||||
|
title: 'Dock protection',
|
||||||
|
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}.`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||||
|
issueCommand(suspect.roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock protection stop failed', suspect.roverId, err.message);
|
||||||
|
}
|
||||||
|
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
|
||||||
|
if (bumpRecent) nudgeRover(suspect.roverId, 'backward');
|
||||||
|
else nudgeRover(suspect.roverId, 'forward');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSensorFrame(roverId, frame) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return;
|
||||||
|
record.lastSeen = Date.now();
|
||||||
|
const decoded = parseSensorFrame(frame.data);
|
||||||
|
record.lastSensor = { raw: frame, decoded };
|
||||||
|
record.batteryState = computeBatteryState(record, decoded);
|
||||||
|
updateMovement(record, decoded);
|
||||||
|
const hasDockInfo = decoded?.chargingSources != null;
|
||||||
|
if (hasDockInfo) {
|
||||||
|
const prevDocked = record.docked;
|
||||||
|
const docked = Boolean(decoded?.chargingSources?.homeBase);
|
||||||
|
record.docked = docked;
|
||||||
|
if (prevDocked === true && docked === false) handleIdleUndock(record);
|
||||||
|
}
|
||||||
|
const bumps = decoded?.bumpsAndWheelDrops;
|
||||||
|
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
||||||
|
handlePrivateButtonHold(record, decoded);
|
||||||
|
evaluatePrivateSafety(record, decoded);
|
||||||
|
io.to(record.room).volatile.emit('sensorFrame', { roverId, frame, sensors: decoded });
|
||||||
|
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
|
||||||
|
evaluateDockGuard(record, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSensorFrame,
|
||||||
|
applyPrivateDriveSafety,
|
||||||
|
stopDockGuard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSensorPipeline,
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// rover Manager socket handlers
|
||||||
|
// Purpose: Encapsulates rover-manager socket event wiring while preserving existing control/lock/spectator behavior.
|
||||||
|
// Scope: Keeps runtime behavior unchanged by delegating all side effects to injected rover-manager callbacks.
|
||||||
|
function registerSocketHandlers(deps) {
|
||||||
|
const {
|
||||||
|
io,
|
||||||
|
logger,
|
||||||
|
MODES,
|
||||||
|
ALERT_COLOR,
|
||||||
|
getMode,
|
||||||
|
isAdmin,
|
||||||
|
isLockdownAdmin,
|
||||||
|
sendAlert,
|
||||||
|
videoSessions,
|
||||||
|
rovers,
|
||||||
|
isPrivateRecord,
|
||||||
|
isRoverVisibleToSocket,
|
||||||
|
tickPrivateAutoClose,
|
||||||
|
removeSocket,
|
||||||
|
enableSpectator,
|
||||||
|
getRosterForSocket,
|
||||||
|
canRequestControl,
|
||||||
|
canSwitchRover,
|
||||||
|
getRoversForSocket,
|
||||||
|
requestControl,
|
||||||
|
releaseControl,
|
||||||
|
managerEvents,
|
||||||
|
setPrivateOpen,
|
||||||
|
lockRover,
|
||||||
|
setPrivateSafety,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
tickPrivateAutoClose();
|
||||||
|
socket.emit('rovers', getRosterForSocket(socket));
|
||||||
|
if (socket.data?.role === 'spectator') {
|
||||||
|
enableSpectator(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
|
||||||
|
try {
|
||||||
|
if (socket.data?.role === 'spectator') {
|
||||||
|
throw new Error('Spectators cannot drive');
|
||||||
|
}
|
||||||
|
const mode = getMode();
|
||||||
|
if (
|
||||||
|
(mode === MODES.ADMIN && !isAdmin(socket)) ||
|
||||||
|
(mode === MODES.LOCKDOWN && !isLockdownAdmin(socket))
|
||||||
|
) {
|
||||||
|
throw new Error('Admins only');
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
const previousJoined = getRoversForSocket(socket.id);
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
const { ok, message } = canSwitchRover(socket, targetId);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(message || 'Switch denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const forceAllowed = Boolean(force) && isAdmin(socket);
|
||||||
|
logger.info('Request control', socket.id, targetId, { force: forceAllowed });
|
||||||
|
requestControl(targetId, socket, { force: forceAllowed, allowUser: true });
|
||||||
|
previousJoined.forEach((rid) => {
|
||||||
|
if (rid !== targetId) {
|
||||||
|
releaseControl(rid, socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
videoSessions.revokeWhere(
|
||||||
|
(info) =>
|
||||||
|
info.socketId === socket.id &&
|
||||||
|
info.sourceType === 'rover' &&
|
||||||
|
info.sourceId !== targetId &&
|
||||||
|
info.sourceId !== `${targetId}-audio`,
|
||||||
|
);
|
||||||
|
managerEvents.emit('switch', { socketId: socket.id, roverId: targetId });
|
||||||
|
socket.emit('controlGranted', { roverId: targetId });
|
||||||
|
cb({ success: true, roverId: targetId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Request control failed', socket.id, err.message);
|
||||||
|
sendAlert({ color: ALERT_COLOR, title: 'Control denied', message: err.message });
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReleaseControl({ roverId } = {}, cb = () => {}) {
|
||||||
|
if (!roverId) {
|
||||||
|
cb({ error: 'roverId required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('Release control', socket.id, roverId);
|
||||||
|
releaseControl(roverId, socket);
|
||||||
|
cb({ success: true, roverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLockToggle({ roverId, locked } = {}, cb = () => {}) {
|
||||||
|
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 {
|
||||||
|
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);
|
||||||
|
sendAlert({ color: ALERT_COLOR, title: 'Lock failed', message: err.message });
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
|
cb({ error: 'Spectating disabled in lockdown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('Spectator subscribing to all rovers', socket.id);
|
||||||
|
for (const record of rovers.values()) {
|
||||||
|
if (isRoverVisibleToSocket(record, socket)) {
|
||||||
|
socket.join(record.room);
|
||||||
|
} else {
|
||||||
|
socket.leave(record.room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('requestControl', handleRequestControl);
|
||||||
|
socket.on('session:requestControl', handleRequestControl);
|
||||||
|
socket.on('releaseControl', handleReleaseControl);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerSocketHandlers,
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// rover Manager spectator access
|
||||||
|
// Purpose: Manages spectator room joins and private auto-close behavior.
|
||||||
|
// Scope: Keeps runtime behavior unchanged while isolating spectator-specific orchestration.
|
||||||
|
function createSpectatorAccess(deps) {
|
||||||
|
const {
|
||||||
|
io,
|
||||||
|
logger,
|
||||||
|
rovers,
|
||||||
|
spectatorSockets,
|
||||||
|
privateNoUsersSince,
|
||||||
|
PRIVATE_AUTO_CLOSE_IDLE_MS,
|
||||||
|
isPrivateRecord,
|
||||||
|
isPrivateOpen,
|
||||||
|
isRoverVisibleToSocket,
|
||||||
|
setPrivateOpen,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function enableSpectator(socket) {
|
||||||
|
if (!socket?.id || spectatorSockets.has(socket.id)) return;
|
||||||
|
spectatorSockets.add(socket.id);
|
||||||
|
for (const record of rovers.values()) {
|
||||||
|
if (isRoverVisibleToSocket(record, socket)) {
|
||||||
|
socket.join(record.room);
|
||||||
|
} else {
|
||||||
|
socket.leave(record.room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableSpectator(socket) {
|
||||||
|
if (!socket?.id || !spectatorSockets.has(socket.id)) return;
|
||||||
|
spectatorSockets.delete(socket.id);
|
||||||
|
for (const record of rovers.values()) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enableSpectator,
|
||||||
|
disableSpectator,
|
||||||
|
tickPrivateAutoClose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSpectatorAccess,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user