mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
the
This commit is contained in:
@@ -54,6 +54,14 @@ roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
||||
if (open) {
|
||||
reassignWaiting();
|
||||
} else {
|
||||
reassignFromRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ action }) => {
|
||||
if (action === 'removed' || action === 'upsert') {
|
||||
reassignWaiting();
|
||||
@@ -77,7 +85,7 @@ function assignSocket(socket) {
|
||||
if (assignments.has(socket.id)) {
|
||||
return;
|
||||
}
|
||||
const target = pickRover();
|
||||
const target = pickRover(socket);
|
||||
if (!target) {
|
||||
waiting.add(socket.id);
|
||||
logger.info('No rover available, user waiting', socket.id);
|
||||
@@ -159,13 +167,15 @@ function forceRelease(roverId, socketId) {
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
|
||||
function pickRover() {
|
||||
function pickRover(socket) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
return null;
|
||||
}
|
||||
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||
if (!rover || rover.locked) return false;
|
||||
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
|
||||
if (!access.ok) return false;
|
||||
return true;
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
|
||||
@@ -89,6 +89,11 @@ function resolveRoverColor(roverId) {
|
||||
return record?.meta?.color || null;
|
||||
}
|
||||
|
||||
function isPrivateClosedRoverId(roverId) {
|
||||
if (!roverId) return false;
|
||||
return roverManager.canReplayRoverId(roverId) !== true;
|
||||
}
|
||||
|
||||
function normalizeUserText(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.replace(/\\n/g, '\n');
|
||||
@@ -406,6 +411,13 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
});
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
const privateClosed = isPrivateClosedRoverId(message.roverId);
|
||||
if (privateClosed) {
|
||||
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
|
||||
maybeSpeak(socket, message, forcedTts);
|
||||
cb({ success: true, privateOnly: true });
|
||||
return;
|
||||
}
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
@@ -466,6 +478,9 @@ function sendExternalMessage({
|
||||
if (isKeymash(clean)) {
|
||||
throw new Error('Message looks like spam');
|
||||
}
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
throw new Error('Private rover chat is closed');
|
||||
}
|
||||
const message = buildMessage(null, clean, {
|
||||
nickname,
|
||||
role,
|
||||
@@ -499,6 +514,9 @@ function sendExternalTyping({
|
||||
discordUserAvatarUrl = null,
|
||||
isTyping = true,
|
||||
}) {
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return null;
|
||||
}
|
||||
const payload = buildTypingPayload(null, {
|
||||
nickname,
|
||||
role,
|
||||
@@ -533,6 +551,9 @@ io.on('connection', (socket) => {
|
||||
typingBySocket.delete(socket.id);
|
||||
}
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
@@ -540,6 +561,9 @@ io.on('connection', (socket) => {
|
||||
if (!typingBySocket.has(socket.id)) return;
|
||||
typingBySocket.delete(socket.id);
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
const roverManager = require('./roverManager');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const logger = require('../globals/logger').child('commandService');
|
||||
|
||||
const pendingCommands = new Map(); // id -> { roverId }
|
||||
@@ -82,12 +82,19 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||
const left = Number(driveDirect.left);
|
||||
const right = Number(driveDirect.right);
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
throw new Error('Drive blocked: dock protection cooldown');
|
||||
const reason = isLockdownAdmin(socket)
|
||||
? 'Drive blocked: cooldown'
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
|
||||
@@ -13,7 +13,8 @@ const logger = require('../globals/logger').child('discordBot');
|
||||
const io = require('../globals/io');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { subscribe } = require('./eventBus');
|
||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getRoster, lockRover, rovers } = roverManager;
|
||||
const { MODES, getMode, setMode } = require('./modeManager');
|
||||
const { sendExternalMessage, sendExternalTyping } = require('./chatService');
|
||||
const { buildReplayVideo } = require('./replayBuildService');
|
||||
@@ -158,6 +159,7 @@ function isCharging(sensors) {
|
||||
|
||||
function buildRoverStatusSnapshot(record) {
|
||||
if (!record) return null;
|
||||
if (!roverManager.canReplayRoverId(record.id)) return null;
|
||||
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null;
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const charging = isCharging(sensors);
|
||||
@@ -179,7 +181,7 @@ function buildRoverStatusSnapshot(record) {
|
||||
}
|
||||
|
||||
function countReady() {
|
||||
const roster = getRoster();
|
||||
const roster = getRoster().filter((entry) => roverManager.canReplayRoverId(entry.id));
|
||||
const total = roster.length;
|
||||
const ready = roster.filter((r) => !r.locked).length;
|
||||
return { ready, total };
|
||||
@@ -1097,7 +1099,9 @@ async function handleTimeStatusCommand(message) {
|
||||
|
||||
function buildBatteryStatusEmbed(color, records = null) {
|
||||
const embed = buildEmbed({ title: 'Rover Battery Status', color: color || 0x2196f3 });
|
||||
const baseRecords = records || Array.from(rovers.values());
|
||||
const baseRecords = (records || Array.from(rovers.values())).filter((entry) =>
|
||||
roverManager.canReplayRoverId(entry?.id),
|
||||
);
|
||||
const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean);
|
||||
if (snapshots.length === 0) {
|
||||
embed.setDescription('No rovers online.');
|
||||
@@ -1135,7 +1139,9 @@ function buildBatteryStatusEmbed(color, records = null) {
|
||||
|
||||
function buildAllUnlockedEmbed(color, records = null) {
|
||||
const embed = buildEmbed({ title: 'All Rovers Unlocked', color: color || 0x4caf50 });
|
||||
const baseRecords = records || Array.from(rovers.values());
|
||||
const baseRecords = (records || Array.from(rovers.values())).filter((entry) =>
|
||||
roverManager.canReplayRoverId(entry?.id),
|
||||
);
|
||||
const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean);
|
||||
if (snapshots.length === 0) {
|
||||
embed.setDescription('No rovers online.');
|
||||
@@ -1158,8 +1164,9 @@ function buildAllUnlockedCaption(records = null) {
|
||||
}
|
||||
|
||||
function buildAccessModeEmbed(mode, color) {
|
||||
const total = rovers.size;
|
||||
const unlocked = Array.from(rovers.values()).filter((entry) => !entry.locked).length;
|
||||
const visible = Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
|
||||
const total = visible.length;
|
||||
const unlocked = visible.filter((entry) => !entry.locked).length;
|
||||
const embed = buildEmbed({
|
||||
title: 'Access Mode Updated',
|
||||
description: `Access mode set to **${mode}**\nUnlocked rovers: **${unlocked}/${total}**`,
|
||||
@@ -1170,6 +1177,9 @@ function buildAccessModeEmbed(mode, color) {
|
||||
|
||||
function buildBatteryCaption(type, payload) {
|
||||
const roverId = payload?.roverId || 'unknown';
|
||||
if (!roverManager.canReplayRoverId(roverId)) {
|
||||
return null;
|
||||
}
|
||||
const record = rovers.get(roverId) || findRoverRecord(roverId);
|
||||
const snapshot = buildRoverStatusSnapshot(record);
|
||||
const base = snapshot?.name || roverId;
|
||||
@@ -1258,6 +1268,10 @@ function handleBusEvent(event) {
|
||||
const { type, payload } = event || {};
|
||||
const channels = discordConfig.channels || {};
|
||||
const roles = discordConfig.roles || {};
|
||||
const roverId = payload?.roverId || null;
|
||||
if (roverId && !roverManager.canReplayRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
switch (type) {
|
||||
case 'mode.changed':
|
||||
if (!skippedFirstModeAnnouncement) {
|
||||
@@ -1342,6 +1356,7 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
case 'battery.warn':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
pingRoleId: roles.adminPing || null,
|
||||
@@ -1353,6 +1368,7 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
case 'battery.urgent':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
pingRoleId: roles.adminPing || null,
|
||||
@@ -1364,6 +1380,7 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
case 'battery.docked':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0x2196f3,
|
||||
@@ -1374,6 +1391,7 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
case 'battery.undocked':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0x2196f3,
|
||||
@@ -1384,6 +1402,7 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
case 'battery.charging.start':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0x2196f3,
|
||||
@@ -1394,6 +1413,7 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
case 'battery.charging.stop':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0xf0b651,
|
||||
@@ -1404,6 +1424,7 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
case 'battery.locked':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0xf0b651,
|
||||
@@ -1415,6 +1436,7 @@ function handleBusEvent(event) {
|
||||
schedulePresenceRotation();
|
||||
break;
|
||||
case 'battery.unlocked':
|
||||
if (!buildBatteryCaption(type, payload)) break;
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0x4caf50,
|
||||
@@ -1528,6 +1550,7 @@ async function handleVerificationReaction(reaction, user) {
|
||||
function handleChatBridgeOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload) return;
|
||||
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return;
|
||||
const guildConfigs = listGuildConfigs();
|
||||
if (!guildConfigs.length) return;
|
||||
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
|
||||
@@ -1566,6 +1589,7 @@ function handleChatBridgeOutbound(event) {
|
||||
function handleChatTypingOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload || payload.fromDiscord) return;
|
||||
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return;
|
||||
const guildConfigs = listGuildConfigs();
|
||||
if (!guildConfigs.length) return;
|
||||
guildConfigs.forEach((entry) => {
|
||||
|
||||
@@ -62,9 +62,19 @@ function sumQueueCounts(turnQueues = {}) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getPublicRovers() {
|
||||
return roverManager
|
||||
.getRoster()
|
||||
.filter((rover) => roverManager.canReplayRoverId(rover.id));
|
||||
}
|
||||
|
||||
function buildEmbedCopy(state, camera) {
|
||||
const roversOnline = state?.rovers?.length || 0;
|
||||
const driverCount = Object.keys(state?.activeDrivers || {}).length;
|
||||
const visibleRoverIds = new Set((state?.rovers || []).map((rover) => String(rover.id)));
|
||||
const driverCount = Object.entries(state?.activeDrivers || {}).reduce((count, [roverId, socketId]) => {
|
||||
if (!socketId) return count;
|
||||
return visibleRoverIds.has(String(roverId)) ? count + 1 : count;
|
||||
}, 0);
|
||||
const mode = state?.mode || 'open';
|
||||
const modeLabel = {
|
||||
open: 'open drive',
|
||||
@@ -148,7 +158,7 @@ async function renderIndexHtml(req) {
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const state = {
|
||||
mode: getMode(),
|
||||
rovers: roverManager.getRoster(),
|
||||
rovers: getPublicRovers(),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
};
|
||||
@@ -216,7 +226,7 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
|
||||
async function renderOgImage() {
|
||||
const state = {
|
||||
mode: getMode(),
|
||||
rovers: roverManager.getRoster(),
|
||||
rovers: getPublicRovers(),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
};
|
||||
|
||||
@@ -278,6 +278,7 @@ function upsertActivityState(roverId) {
|
||||
|
||||
function onSensorEvent({ roverId, sensors, batteryState } = {}) {
|
||||
if (!roverId || !sensors) return;
|
||||
if (!roverManager.canReplayRoverId(roverId)) return;
|
||||
const nowMs = Date.now();
|
||||
const dockedNow = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const bucketTs = Math.floor(nowMs / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS;
|
||||
@@ -603,7 +604,10 @@ function deriveMobilityState(sensors = {}, wheelsOffGround = false) {
|
||||
|
||||
function buildRoversNow(nowMs = Date.now()) {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roster = roverManager.getRoster().slice(0, MAX_ROVERS);
|
||||
const roster = roverManager
|
||||
.getRoster()
|
||||
.filter((entry) => roverManager.canReplayRoverId(entry.id))
|
||||
.slice(0, MAX_ROVERS);
|
||||
const nextRoverStateById = new Map();
|
||||
const rovers = roster.map((entry) => {
|
||||
const roverId = String(entry.id);
|
||||
@@ -693,7 +697,12 @@ function buildSnapshot() {
|
||||
const { rovers } = buildRoversNow(nowMs);
|
||||
const roverById = new Map(rovers.map((rover) => [String(rover.id), rover]));
|
||||
const allRecentMessages = getRecentMessages(300, { includeSystem: true })
|
||||
.filter((entry) => Number(entry?.ts) >= contextResetAt);
|
||||
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
||||
.filter((entry) => {
|
||||
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
||||
if (!roverId) return true;
|
||||
return roverManager.canReplayRoverId(roverId);
|
||||
});
|
||||
const chatRecent = allRecentMessages
|
||||
.filter((entry) => !entry?.system)
|
||||
.slice(-MAX_CHAT_MESSAGES)
|
||||
@@ -710,7 +719,11 @@ function buildSnapshot() {
|
||||
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
|
||||
);
|
||||
|
||||
const roverEvents = roverMajorEvents.filter((entry) => Number(entry?.ts) >= contextResetAt);
|
||||
const roverEvents = roverMajorEvents.filter(
|
||||
(entry) =>
|
||||
Number(entry?.ts) >= contextResetAt &&
|
||||
roverManager.canReplayRoverId(entry?.rover_id || ''),
|
||||
);
|
||||
const timelineEntries = [
|
||||
...allRecentMessages.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'chat', entry })),
|
||||
...roverEvents.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'event', entry })),
|
||||
|
||||
@@ -210,11 +210,26 @@ function stopRecorder(key) {
|
||||
recorders.delete(key);
|
||||
}
|
||||
|
||||
async function removeSourceArtifacts(key, source) {
|
||||
try {
|
||||
await fsp.rm(path.join(SEGMENT_DIR, key), { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clear replay segments', { key, error: err.message });
|
||||
}
|
||||
if (source?.type === 'rover' && source?.id) {
|
||||
try {
|
||||
await fsp.rm(path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`), { force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clear rover snapshot artifact', { roverId: source.id, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRecord(source) {
|
||||
if (source.type === 'room') {
|
||||
return Boolean(source.streamUrl);
|
||||
}
|
||||
return true;
|
||||
return roverManager.canReplayRoverId(source.id);
|
||||
}
|
||||
|
||||
function syncRecorders() {
|
||||
@@ -230,7 +245,9 @@ function syncRecorders() {
|
||||
});
|
||||
Array.from(recorders.keys()).forEach((key) => {
|
||||
if (!desiredKeys.has(key)) {
|
||||
const entry = recorders.get(key);
|
||||
stopRecorder(key);
|
||||
removeSourceArtifacts(key, entry?.source).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -295,6 +312,10 @@ roverManager.managerEvents.on('rover', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('private', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
start();
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -27,10 +27,10 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
|
||||
let sources = requestedSources ? validateSources(requestedSources) : [];
|
||||
let sources = requestedSources ? validateSources(requestedSources, socket) : [];
|
||||
if (!sources.length) {
|
||||
const assignment = assignmentService.describeAssignment(socket.id);
|
||||
sources = getDefaultWebSources(assignment);
|
||||
sources = getDefaultWebSources(assignment, socket);
|
||||
}
|
||||
if (!sources.length) {
|
||||
cb({ error: 'No replay sources selected', state: null });
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
const roverManager = require('./roverManager');
|
||||
const { getRoomCameras } = require('./roomCameraService');
|
||||
|
||||
function getReplaySources() {
|
||||
const roverSources = roverManager.getRoster().map((rover) => ({
|
||||
type: 'rover',
|
||||
id: String(rover.id),
|
||||
label: rover.name || rover.id,
|
||||
color: rover.color || null,
|
||||
}));
|
||||
function getReplaySources(socket = null) {
|
||||
const roster = socket ? roverManager.getRosterForSocket(socket) : roverManager.getRoster();
|
||||
const roverSources = roster
|
||||
.filter((rover) => roverManager.canReplayRoverId(rover.id))
|
||||
.map((rover) => ({
|
||||
type: 'rover',
|
||||
id: String(rover.id),
|
||||
label: rover.name || rover.id,
|
||||
color: rover.color || null,
|
||||
}));
|
||||
const roomSources = getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
@@ -31,9 +34,9 @@ function normalizeSource(entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateSources(list = []) {
|
||||
function validateSources(list = [], socket = null) {
|
||||
const allowed = new Map();
|
||||
getReplaySources().forEach((source) => {
|
||||
getReplaySources(socket).forEach((source) => {
|
||||
allowed.set(`${source.type}:${source.id}`, source);
|
||||
});
|
||||
const unique = new Map();
|
||||
@@ -48,11 +51,12 @@ function validateSources(list = []) {
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function getDefaultWebSources(assignment = {}) {
|
||||
function getDefaultWebSources(assignment = {}, socket = null) {
|
||||
if (assignment?.roverId) {
|
||||
const id = String(assignment.roverId);
|
||||
const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id);
|
||||
return [{ type: 'rover', id, label: match?.label || id }];
|
||||
const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id);
|
||||
if (!match) return [];
|
||||
return [{ type: 'rover', id, label: match.label || id }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -117,11 +117,13 @@ roverSnapshotEvents.on('status', ({ id, error }) => {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('roverSnapshot:subscribe', (payload = {}, cb = () => {}) => {
|
||||
const visibleRoster = roverManager.getRosterForSocket(socket);
|
||||
const visibleIds = visibleRoster.map((rover) => String(rover.id));
|
||||
const list = Array.isArray(payload?.ids)
|
||||
? payload.ids.map(String)
|
||||
: payload?.roverId || payload?.id
|
||||
? [String(payload.roverId || payload.id)]
|
||||
: roverManager.getRoster().map((rover) => rover.id);
|
||||
: visibleIds;
|
||||
const uniqueIds = Array.from(new Set(list));
|
||||
try {
|
||||
if (!allowSubscribe(socket.id)) {
|
||||
@@ -131,7 +133,7 @@ io.on('connection', (socket) => {
|
||||
if (!canViewSnapshots(socket)) {
|
||||
throw new Error('Not authorized for rover snapshots');
|
||||
}
|
||||
const rosterIds = new Set(roverManager.getRoster().map((entry) => String(entry.id)));
|
||||
const rosterIds = new Set(visibleIds);
|
||||
const validIds = uniqueIds.filter((id) => rosterIds.has(String(id)));
|
||||
validIds.forEach((roverId) => addSubscription(socket, roverId));
|
||||
validIds.forEach((roverId) => {
|
||||
|
||||
@@ -54,10 +54,42 @@ function buildUserEntry(socket) {
|
||||
};
|
||||
}
|
||||
|
||||
function filterVisibleRoverId(socket, roverId) {
|
||||
if (!roverId) return null;
|
||||
return roverManager.canSeeRover(roverId, socket) ? roverId : null;
|
||||
}
|
||||
|
||||
function filterActiveDriversForSocket(activeDrivers = {}, socket) {
|
||||
const next = {};
|
||||
Object.entries(activeDrivers || {}).forEach(([roverId, socketId]) => {
|
||||
if (!roverManager.canSeeRover(roverId, socket)) return;
|
||||
next[roverId] = socketId;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function filterTurnQueuesForSocket(turnQueues = {}, socket) {
|
||||
const next = {};
|
||||
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
|
||||
if (!roverManager.canSeeRover(roverId, socket)) return;
|
||||
next[roverId] = info;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildSession(socket) {
|
||||
const users = Array.from(io.sockets.sockets.values())
|
||||
.map((sock) => buildUserEntry(sock))
|
||||
.filter(Boolean);
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
roverId: filterVisibleRoverId(socket, entry.roverId),
|
||||
}));
|
||||
const roster = roverManager.getRosterForSocket(socket);
|
||||
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
||||
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
|
||||
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
|
||||
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
|
||||
const socials =
|
||||
configuredSocials?.length
|
||||
? configuredSocials
|
||||
@@ -70,14 +102,18 @@ function buildSession(socket) {
|
||||
role: getRole(socket),
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
roster: roverManager.getRoster(),
|
||||
assignment: assignmentService.describeAssignment(socket?.id || ''),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
roster,
|
||||
assignment: {
|
||||
...assignment,
|
||||
roverId: assignmentRoverId,
|
||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||
},
|
||||
activeDrivers,
|
||||
turnQueues,
|
||||
roomCameras: getRoomCameras(),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(),
|
||||
replaySources: getReplaySources(socket),
|
||||
health: getHealthSnapshot(),
|
||||
communityGoal: getCommunityGoal(),
|
||||
adminReason: getAdminReason(),
|
||||
@@ -168,6 +204,16 @@ managerEvents.on('lock', ({ roverId, locked }) => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
managerEvents.on('private', ({ roverId, open }) => {
|
||||
logger.info('Private rover visibility change', roverId, open);
|
||||
syncAll();
|
||||
});
|
||||
|
||||
managerEvents.on('privateSafety', ({ roverId }) => {
|
||||
logger.info('Private rover safety config changed', roverId);
|
||||
syncAll();
|
||||
});
|
||||
|
||||
managerEvents.on('driver', ({ socketId }) => {
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
|
||||
@@ -162,6 +162,12 @@ app.post('/mediamtx/auth', (req, res) => {
|
||||
if (!canView(socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
if (streamInfo.type === 'rover') {
|
||||
const roverId = streamInfo.baseId || streamInfo.id;
|
||||
if (!roverManager.canSeeRover(roverId, socket)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
}
|
||||
if (info.sourceType === 'roverMic' && action === 'publish') {
|
||||
const roverId = streamInfo.baseId || streamInfo.id;
|
||||
if (!isVerified(socket)) {
|
||||
|
||||
@@ -53,6 +53,9 @@ function canViewRover(socket, roverId) {
|
||||
if (!passesMode(socket)) {
|
||||
return false;
|
||||
}
|
||||
if (!roverManager.canSeeRover(roverId, socket)) {
|
||||
return false;
|
||||
}
|
||||
const role = getRole(socket);
|
||||
if (role === 'spectator' || isAdmin(socket)) {
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user