This commit is contained in:
legop3
2026-03-24 02:48:16 -04:00
parent e0167939d7
commit 3af21846c8
32 changed files with 1178 additions and 202 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1
View File
@@ -11,6 +11,7 @@ type helloMessage struct {
Audio AudioConfig `json:"audio"` Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"` Horn HornConfig `json:"horn"`
NightVision NightVisionConfig `json:"nightVision"` NightVision NightVisionConfig `json:"nightVision"`
Private PrivateConfig `json:"private"`
} }
type sensorMessage struct { type sensorMessage struct {
+36
View File
@@ -123,6 +123,25 @@ type AutoSideBrushConfig struct {
Speed int `yaml:"speed"` Speed int `yaml:"speed"`
} }
type PrivateConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Safety PrivateSafetyConfig `yaml:"safety" json:"safety"`
}
type PrivateSafetyConfig struct {
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
}
type Config struct { type Config struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Color string `yaml:"color" json:"color,omitempty"` Color string `yaml:"color" json:"color,omitempty"`
@@ -137,6 +156,7 @@ type Config struct {
Horn HornConfig `yaml:"horn"` Horn HornConfig `yaml:"horn"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"` NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"` AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
Private PrivateConfig `yaml:"private" json:"private"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
@@ -203,6 +223,22 @@ func LoadConfig(path string) (*Config, error) {
Enabled: true, Enabled: true,
Speed: 20, Speed: 20,
}, },
Private: PrivateConfig{
Enabled: false,
Safety: PrivateSafetyConfig{
SpeedLimitEnabled: false,
SpeedLimitMaxWheelMMs: 250,
HardOvercurrentEnabled: false,
OvercurrentStopMs: 300,
HardBumpEnabled: false,
BumpBackoffSpeed: 250,
BumpBackoffMs: 350,
CliffEnabled: false,
CliffBackoffSpeed: 250,
CliffBackoffMs: 500,
TriggerCooldownMs: 800,
},
},
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
BIN
View File
Binary file not shown.
+14
View File
@@ -64,3 +64,17 @@ nightVision:
autoSideBrush: autoSideBrush:
enabled: true enabled: true
speed: 20 speed: 20
private:
enabled: false
safety:
speedLimitEnabled: false
speedLimitMaxWheelSpeed: 250
hardOvercurrentEnabled: false
overcurrentStopMs: 300
hardBumpEnabled: false
bumpBackoffSpeed: 250
bumpBackoffMs: 350
cliffEnabled: false
cliffBackoffSpeed: 250
cliffBackoffMs: 500
triggerCooldownMs: 800
+14
View File
@@ -34,3 +34,17 @@ cameraServo:
autoSideBrush: autoSideBrush:
enabled: true enabled: true
speed: 20 speed: 20
private:
enabled: false
safety:
speedLimitEnabled: false
speedLimitMaxWheelSpeed: 250
hardOvercurrentEnabled: false
overcurrentStopMs: 300
hardBumpEnabled: false
bumpBackoffSpeed: 250
bumpBackoffMs: 350
cliffEnabled: false
cliffBackoffSpeed: 250
cliffBackoffMs: 500
triggerCooldownMs: 800
+1
View File
@@ -121,6 +121,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Audio: c.cfg.Audio, Audio: c.cfg.Audio,
Horn: c.cfg.Horn, Horn: c.cfg.Horn,
NightVision: c.cfg.NightVision, NightVision: c.cfg.NightVision,
Private: c.cfg.Private,
} }
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin) c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg) return writeJSON(ctx, conn, msg)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BoGitFQI.js"></script> <script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bzl2VfGU.css"> <link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+12 -2
View File
@@ -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 }) => { roverManager.managerEvents.on('rover', ({ action }) => {
if (action === 'removed' || action === 'upsert') { if (action === 'removed' || action === 'upsert') {
reassignWaiting(); reassignWaiting();
@@ -77,7 +85,7 @@ function assignSocket(socket) {
if (assignments.has(socket.id)) { if (assignments.has(socket.id)) {
return; return;
} }
const target = pickRover(); const target = pickRover(socket);
if (!target) { if (!target) {
waiting.add(socket.id); waiting.add(socket.id);
logger.info('No rover available, user waiting', socket.id); logger.info('No rover available, user waiting', socket.id);
@@ -159,13 +167,15 @@ function forceRelease(roverId, socketId) {
assignmentEvents.emit('update', socketId); assignmentEvents.emit('update', socketId);
} }
function pickRover() { function pickRover(socket) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
return null; return null;
} }
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => { const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
if (!rover || rover.locked) return false; if (!rover || rover.locked) return false;
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
if (!access.ok) return false;
return true; return true;
}); });
if (candidates.length === 0) { if (candidates.length === 0) {
+24
View File
@@ -89,6 +89,11 @@ function resolveRoverColor(roverId) {
return record?.meta?.color || null; return record?.meta?.color || null;
} }
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
return roverManager.canReplayRoverId(roverId) !== true;
}
function normalizeUserText(raw) { function normalizeUserText(raw) {
if (typeof raw !== 'string') return ''; if (typeof raw !== 'string') return '';
return raw.replace(/\\n/g, '\n'); 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 }); logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id); 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); broadcastMessage(message);
maybeSendAccessNotice(message); maybeSendAccessNotice(message);
maybeSpeak(socket, message, ttsOptions); maybeSpeak(socket, message, ttsOptions);
@@ -466,6 +478,9 @@ function sendExternalMessage({
if (isKeymash(clean)) { if (isKeymash(clean)) {
throw new Error('Message looks like spam'); throw new Error('Message looks like spam');
} }
if (isPrivateClosedRoverId(roverId)) {
throw new Error('Private rover chat is closed');
}
const message = buildMessage(null, clean, { const message = buildMessage(null, clean, {
nickname, nickname,
role, role,
@@ -499,6 +514,9 @@ function sendExternalTyping({
discordUserAvatarUrl = null, discordUserAvatarUrl = null,
isTyping = true, isTyping = true,
}) { }) {
if (isPrivateClosedRoverId(roverId)) {
return null;
}
const payload = buildTypingPayload(null, { const payload = buildTypingPayload(null, {
nickname, nickname,
role, role,
@@ -533,6 +551,9 @@ io.on('connection', (socket) => {
typingBySocket.delete(socket.id); typingBySocket.delete(socket.id);
} }
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping }); const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
broadcastTyping(typingPayload); broadcastTyping(typingPayload);
}); });
@@ -540,6 +561,9 @@ io.on('connection', (socket) => {
if (!typingBySocket.has(socket.id)) return; if (!typingBySocket.has(socket.id)) return;
typingBySocket.delete(socket.id); typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }); const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
broadcastTyping(typingPayload); broadcastTyping(typingPayload);
}); });
+11 -4
View File
@@ -1,7 +1,7 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../globals/io');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { isAdmin } = require('./roleService'); const { isAdmin, isLockdownAdmin } = require('./roleService');
const logger = require('../globals/logger').child('commandService'); const logger = require('../globals/logger').child('commandService');
const pendingCommands = new Map(); // id -> { roverId } const pendingCommands = new Map(); // id -> { roverId }
@@ -82,12 +82,19 @@ io.on('connection', (socket) => {
} }
const driveDirect = payload?.driveDirect; const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect && !isAdminSocket) { if (type === 'drive' && driveDirect && !isAdminSocket) {
const left = Number(driveDirect.left); const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
const right = Number(driveDirect.right); 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 speed = Math.max(Math.abs(left), Math.abs(right));
const blockedUntil = driveCooldowns.get(roverId); const blockedUntil = driveCooldowns.get(roverId);
if (blockedUntil && Date.now() < blockedUntil && speed > 0) { 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) { if (speed > 0) {
let direction = 'turn'; let direction = 'turn';
+30 -6
View File
@@ -13,7 +13,8 @@ const logger = require('../globals/logger').child('discordBot');
const io = require('../globals/io'); const io = require('../globals/io');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { subscribe } = require('./eventBus'); 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 { MODES, getMode, setMode } = require('./modeManager');
const { sendExternalMessage, sendExternalTyping } = require('./chatService'); const { sendExternalMessage, sendExternalTyping } = require('./chatService');
const { buildReplayVideo } = require('./replayBuildService'); const { buildReplayVideo } = require('./replayBuildService');
@@ -158,6 +159,7 @@ function isCharging(sensors) {
function buildRoverStatusSnapshot(record) { function buildRoverStatusSnapshot(record) {
if (!record) return null; if (!record) return null;
if (!roverManager.canReplayRoverId(record.id)) return null;
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null; const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null;
const docked = Boolean(sensors?.chargingSources?.homeBase); const docked = Boolean(sensors?.chargingSources?.homeBase);
const charging = isCharging(sensors); const charging = isCharging(sensors);
@@ -179,7 +181,7 @@ function buildRoverStatusSnapshot(record) {
} }
function countReady() { function countReady() {
const roster = getRoster(); const roster = getRoster().filter((entry) => roverManager.canReplayRoverId(entry.id));
const total = roster.length; const total = roster.length;
const ready = roster.filter((r) => !r.locked).length; const ready = roster.filter((r) => !r.locked).length;
return { ready, total }; return { ready, total };
@@ -1097,7 +1099,9 @@ async function handleTimeStatusCommand(message) {
function buildBatteryStatusEmbed(color, records = null) { function buildBatteryStatusEmbed(color, records = null) {
const embed = buildEmbed({ title: 'Rover Battery Status', color: color || 0x2196f3 }); 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); const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean);
if (snapshots.length === 0) { if (snapshots.length === 0) {
embed.setDescription('No rovers online.'); embed.setDescription('No rovers online.');
@@ -1135,7 +1139,9 @@ function buildBatteryStatusEmbed(color, records = null) {
function buildAllUnlockedEmbed(color, records = null) { function buildAllUnlockedEmbed(color, records = null) {
const embed = buildEmbed({ title: 'All Rovers Unlocked', color: color || 0x4caf50 }); 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); const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean);
if (snapshots.length === 0) { if (snapshots.length === 0) {
embed.setDescription('No rovers online.'); embed.setDescription('No rovers online.');
@@ -1158,8 +1164,9 @@ function buildAllUnlockedCaption(records = null) {
} }
function buildAccessModeEmbed(mode, color) { function buildAccessModeEmbed(mode, color) {
const total = rovers.size; const visible = Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
const unlocked = Array.from(rovers.values()).filter((entry) => !entry.locked).length; const total = visible.length;
const unlocked = visible.filter((entry) => !entry.locked).length;
const embed = buildEmbed({ const embed = buildEmbed({
title: 'Access Mode Updated', title: 'Access Mode Updated',
description: `Access mode set to **${mode}**\nUnlocked rovers: **${unlocked}/${total}**`, description: `Access mode set to **${mode}**\nUnlocked rovers: **${unlocked}/${total}**`,
@@ -1170,6 +1177,9 @@ function buildAccessModeEmbed(mode, color) {
function buildBatteryCaption(type, payload) { function buildBatteryCaption(type, payload) {
const roverId = payload?.roverId || 'unknown'; const roverId = payload?.roverId || 'unknown';
if (!roverManager.canReplayRoverId(roverId)) {
return null;
}
const record = rovers.get(roverId) || findRoverRecord(roverId); const record = rovers.get(roverId) || findRoverRecord(roverId);
const snapshot = buildRoverStatusSnapshot(record); const snapshot = buildRoverStatusSnapshot(record);
const base = snapshot?.name || roverId; const base = snapshot?.name || roverId;
@@ -1258,6 +1268,10 @@ function handleBusEvent(event) {
const { type, payload } = event || {}; const { type, payload } = event || {};
const channels = discordConfig.channels || {}; const channels = discordConfig.channels || {};
const roles = discordConfig.roles || {}; const roles = discordConfig.roles || {};
const roverId = payload?.roverId || null;
if (roverId && !roverManager.canReplayRoverId(roverId)) {
return;
}
switch (type) { switch (type) {
case 'mode.changed': case 'mode.changed':
if (!skippedFirstModeAnnouncement) { if (!skippedFirstModeAnnouncement) {
@@ -1342,6 +1356,7 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.warn': case 'battery.warn':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null, pingRoleId: roles.adminPing || null,
@@ -1353,6 +1368,7 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.urgent': case 'battery.urgent':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null, pingRoleId: roles.adminPing || null,
@@ -1364,6 +1380,7 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.docked': case 'battery.docked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x2196f3, color: 0x2196f3,
@@ -1374,6 +1391,7 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.undocked': case 'battery.undocked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x2196f3, color: 0x2196f3,
@@ -1384,6 +1402,7 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.charging.start': case 'battery.charging.start':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x2196f3, color: 0x2196f3,
@@ -1394,6 +1413,7 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.charging.stop': case 'battery.charging.stop':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0xf0b651, color: 0xf0b651,
@@ -1404,6 +1424,7 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.locked': case 'battery.locked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0xf0b651, color: 0xf0b651,
@@ -1415,6 +1436,7 @@ function handleBusEvent(event) {
schedulePresenceRotation(); schedulePresenceRotation();
break; break;
case 'battery.unlocked': case 'battery.unlocked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x4caf50, color: 0x4caf50,
@@ -1528,6 +1550,7 @@ async function handleVerificationReaction(reaction, user) {
function handleChatBridgeOutbound(event) { function handleChatBridgeOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload) return; if (!payload) return;
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return;
const guildConfigs = listGuildConfigs(); const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return; if (!guildConfigs.length) return;
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text; const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
@@ -1566,6 +1589,7 @@ function handleChatBridgeOutbound(event) {
function handleChatTypingOutbound(event) { function handleChatTypingOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload || payload.fromDiscord) return; if (!payload || payload.fromDiscord) return;
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return;
const guildConfigs = listGuildConfigs(); const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return; if (!guildConfigs.length) return;
guildConfigs.forEach((entry) => { guildConfigs.forEach((entry) => {
+13 -3
View File
@@ -62,9 +62,19 @@ function sumQueueCounts(turnQueues = {}) {
}, 0); }, 0);
} }
function getPublicRovers() {
return roverManager
.getRoster()
.filter((rover) => roverManager.canReplayRoverId(rover.id));
}
function buildEmbedCopy(state, camera) { function buildEmbedCopy(state, camera) {
const roversOnline = state?.rovers?.length || 0; 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 mode = state?.mode || 'open';
const modeLabel = { const modeLabel = {
open: 'open drive', open: 'open drive',
@@ -148,7 +158,7 @@ async function renderIndexHtml(req) {
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const state = { const state = {
mode: getMode(), mode: getMode(),
rovers: roverManager.getRoster(), rovers: getPublicRovers(),
activeDrivers: getActiveDrivers(), activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
}; };
@@ -216,7 +226,7 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
async function renderOgImage() { async function renderOgImage() {
const state = { const state = {
mode: getMode(), mode: getMode(),
rovers: roverManager.getRoster(), rovers: getPublicRovers(),
activeDrivers: getActiveDrivers(), activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
}; };
+16 -3
View File
@@ -278,6 +278,7 @@ function upsertActivityState(roverId) {
function onSensorEvent({ roverId, sensors, batteryState } = {}) { function onSensorEvent({ roverId, sensors, batteryState } = {}) {
if (!roverId || !sensors) return; if (!roverId || !sensors) return;
if (!roverManager.canReplayRoverId(roverId)) return;
const nowMs = Date.now(); const nowMs = Date.now();
const dockedNow = Boolean(sensors?.chargingSources?.homeBase); const dockedNow = Boolean(sensors?.chargingSources?.homeBase);
const bucketTs = Math.floor(nowMs / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS; 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()) { function buildRoversNow(nowMs = Date.now()) {
const activeDrivers = getActiveDrivers(); 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 nextRoverStateById = new Map();
const rovers = roster.map((entry) => { const rovers = roster.map((entry) => {
const roverId = String(entry.id); const roverId = String(entry.id);
@@ -693,7 +697,12 @@ function buildSnapshot() {
const { rovers } = buildRoversNow(nowMs); const { rovers } = buildRoversNow(nowMs);
const roverById = new Map(rovers.map((rover) => [String(rover.id), rover])); const roverById = new Map(rovers.map((rover) => [String(rover.id), rover]));
const allRecentMessages = getRecentMessages(300, { includeSystem: true }) 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 const chatRecent = allRecentMessages
.filter((entry) => !entry?.system) .filter((entry) => !entry?.system)
.slice(-MAX_CHAT_MESSAGES) .slice(-MAX_CHAT_MESSAGES)
@@ -710,7 +719,11 @@ function buildSnapshot() {
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS, (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 = [ const timelineEntries = [
...allRecentMessages.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'chat', entry })), ...allRecentMessages.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'chat', entry })),
...roverEvents.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'event', entry })), ...roverEvents.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'event', entry })),
+22 -1
View File
@@ -210,11 +210,26 @@ function stopRecorder(key) {
recorders.delete(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) { function shouldRecord(source) {
if (source.type === 'room') { if (source.type === 'room') {
return Boolean(source.streamUrl); return Boolean(source.streamUrl);
} }
return true; return roverManager.canReplayRoverId(source.id);
} }
function syncRecorders() { function syncRecorders() {
@@ -230,7 +245,9 @@ function syncRecorders() {
}); });
Array.from(recorders.keys()).forEach((key) => { Array.from(recorders.keys()).forEach((key) => {
if (!desiredKeys.has(key)) { if (!desiredKeys.has(key)) {
const entry = recorders.get(key);
stopRecorder(key); stopRecorder(key);
removeSourceArtifacts(key, entry?.source).catch(() => {});
} }
}); });
} }
@@ -295,6 +312,10 @@ roverManager.managerEvents.on('rover', () => {
syncRecorders(); syncRecorders();
}); });
roverManager.managerEvents.on('private', () => {
syncRecorders();
});
start(); start();
module.exports = { module.exports = {
+2 -2
View File
@@ -27,10 +27,10 @@ io.on('connection', (socket) => {
return; return;
} }
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null; const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
let sources = requestedSources ? validateSources(requestedSources) : []; let sources = requestedSources ? validateSources(requestedSources, socket) : [];
if (!sources.length) { if (!sources.length) {
const assignment = assignmentService.describeAssignment(socket.id); const assignment = assignmentService.describeAssignment(socket.id);
sources = getDefaultWebSources(assignment); sources = getDefaultWebSources(assignment, socket);
} }
if (!sources.length) { if (!sources.length) {
cb({ error: 'No replay sources selected', state: null }); cb({ error: 'No replay sources selected', state: null });
+16 -12
View File
@@ -1,13 +1,16 @@
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { getRoomCameras } = require('./roomCameraService'); const { getRoomCameras } = require('./roomCameraService');
function getReplaySources() { function getReplaySources(socket = null) {
const roverSources = roverManager.getRoster().map((rover) => ({ const roster = socket ? roverManager.getRosterForSocket(socket) : roverManager.getRoster();
type: 'rover', const roverSources = roster
id: String(rover.id), .filter((rover) => roverManager.canReplayRoverId(rover.id))
label: rover.name || rover.id, .map((rover) => ({
color: rover.color || null, type: 'rover',
})); id: String(rover.id),
label: rover.name || rover.id,
color: rover.color || null,
}));
const roomSources = getRoomCameras().map((camera) => ({ const roomSources = getRoomCameras().map((camera) => ({
type: 'room', type: 'room',
id: String(camera.id), id: String(camera.id),
@@ -31,9 +34,9 @@ function normalizeSource(entry) {
return null; return null;
} }
function validateSources(list = []) { function validateSources(list = [], socket = null) {
const allowed = new Map(); const allowed = new Map();
getReplaySources().forEach((source) => { getReplaySources(socket).forEach((source) => {
allowed.set(`${source.type}:${source.id}`, source); allowed.set(`${source.type}:${source.id}`, source);
}); });
const unique = new Map(); const unique = new Map();
@@ -48,11 +51,12 @@ function validateSources(list = []) {
return Array.from(unique.values()); return Array.from(unique.values());
} }
function getDefaultWebSources(assignment = {}) { function getDefaultWebSources(assignment = {}, socket = null) {
if (assignment?.roverId) { if (assignment?.roverId) {
const id = String(assignment.roverId); const id = String(assignment.roverId);
const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id); const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id);
return [{ type: 'rover', id, label: match?.label || id }]; if (!match) return [];
return [{ type: 'rover', id, label: match.label || id }];
} }
return []; return [];
} }
+633 -29
View File
@@ -21,8 +21,171 @@ const DOCK_GUARD_RETRY_MS = 10 * 1000;
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64'); const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
const BACKOFF_MS = 500; const BACKOFF_MS = 500;
const BACKOFF_SPEED = 300; 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 backoffTimers = new Map(); // roverId -> Timeout
const dockGuardStates = new Map(); // roverId -> guard state 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) { function ensureRecord(id) {
if (!rovers.has(id)) { if (!rovers.has(id)) {
@@ -41,6 +204,9 @@ function ensureRecord(id) {
room: `rover:${id}`, room: `rover:${id}`,
lastSeen: Date.now(), lastSeen: Date.now(),
lastMovementAt: Date.now(), lastMovementAt: Date.now(),
private: { enabled: false },
privateOpen: true,
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
}); });
} }
return rovers.get(id); return rovers.get(id);
@@ -58,6 +224,17 @@ function upsertRover(meta, ws) {
record.meta = meta; record.meta = meta;
record.ws = ws; record.ws = ws;
record.lastSeen = Date.now(); 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) { if (record.nightVisionState == null && meta?.nightVision?.enabled) {
const ledOn = Boolean(meta.nightVision.initialOn); const ledOn = Boolean(meta.nightVision.initialOn);
record.nightVisionState = { record.nightVisionState = {
@@ -68,7 +245,12 @@ function upsertRover(meta, ws) {
rovers.set(id, record); rovers.set(id, record);
spectatorSockets.forEach((socketId) => { spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(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 }); managerEvents.emit('rover', { roverId: id, action: 'upsert', record });
if (isNew) { if (isNew) {
@@ -83,6 +265,11 @@ function removeRover(id) {
if (!record) return; if (!record) return;
rovers.delete(id); rovers.delete(id);
stopDockGuard(id); stopDockGuard(id);
privateButtonStates.delete(id);
privateNoUsersSince.delete(id);
privateSafetyStates.delete(id);
clearTimeout(privateSafetyTimers.get(id));
privateSafetyTimers.delete(id);
turnService.cleanupRover(id); turnService.cleanupRover(id);
spectatorSockets.forEach((socketId) => { spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId); const sock = io.sockets.sockets.get(socketId);
@@ -93,6 +280,27 @@ function removeRover(id) {
publishEvent({ source: 'roverManager', type: 'rover.offline', payload: { roverId: 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 = {}) { function lockRover(id, locked, options = {}) {
const record = rovers.get(id); const record = rovers.get(id);
if (!record) { if (!record) {
@@ -141,6 +349,69 @@ function lockRover(id, locked, options = {}) {
return record.locked; 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() { function getRoster() {
return Array.from(rovers.values()).map((record) => ({ return Array.from(rovers.values()).map((record) => ({
id: record.id, id: record.id,
@@ -156,14 +427,51 @@ function getRoster() {
nightVision: record.meta?.nightVision nightVision: record.meta?.nightVision
? { ...record.meta.nightVision, state: record.nightVisionState } ? { ...record.meta.nightVision, state: record.nightVisionState }
: record.meta?.nightVision, : record.meta?.nightVision,
locked: record.locked, locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason: record.lockReason, lockReason:
record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
lastSeen: record.lastSeen, 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() { function broadcastRoster() {
io.emit('rovers', getRoster()); syncSpectatorRooms();
io.sockets.sockets.forEach((socket) => {
socket.emit('rovers', getRosterForSocket(socket));
});
} }
function setNightVisionState(roverId, nightVisionOn) { function setNightVisionState(roverId, nightVisionOn) {
@@ -247,6 +555,192 @@ function computeBatteryDisplayPercent({ charge, full, warn, urgent, percent, cap
return null; 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) { function handleSensorFrame(roverId, frame) {
const record = rovers.get(roverId); const record = rovers.get(roverId);
if (!record) return; if (!record) return;
@@ -268,6 +762,8 @@ function handleSensorFrame(roverId, frame) {
if (bumps?.bumpLeft || bumps?.bumpRight) { if (bumps?.bumpLeft || bumps?.bumpRight) {
record.lastBumpAt = Date.now(); record.lastBumpAt = Date.now();
} }
handlePrivateButtonHold(record, decoded);
evaluatePrivateSafety(record, decoded);
io.to(record.room).volatile.emit('sensorFrame', { io.to(record.room).volatile.emit('sensorFrame', {
roverId, roverId,
frame, frame,
@@ -489,21 +985,9 @@ function removeSocket(socket) {
function requestControl(roverId, socket, options = {}) { function requestControl(roverId, socket, options = {}) {
const { force = false, allowUser = false } = options; const { force = false, allowUser = false } = options;
const record = rovers.get(roverId); const record = rovers.get(roverId);
if (!record) { const denied = getControlDenialReason(record, socket, { allowUser });
throw new Error('Unknown rover'); if (denied) {
} throw new Error(denied);
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');
} }
record.drivers.add(socket.id); record.drivers.add(socket.id);
if (!socketToRovers.has(socket.id)) { if (!socketToRovers.has(socket.id)) {
@@ -545,10 +1029,13 @@ function isDriver(roverId, socket) {
} }
function canDrive(roverId, socket) { function canDrive(roverId, socket) {
const mode = getMode(); const record = rovers.get(roverId);
if (mode === MODES.LOCKDOWN) { if (!record) return false;
return isLockdownAdmin(socket); const denied = getControlDenialReason(record, socket, { allowUser: true });
if (denied) {
return false;
} }
const mode = getMode();
if (isAdmin(socket)) { if (isAdmin(socket)) {
return true; return true;
} }
@@ -594,6 +1081,14 @@ function hasOtherDrivers(record, socketId) {
} }
function canSwitchRover(socket, targetRoverId) { 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); const currentId = getPrimaryRoverForSocket(socket.id);
if (!currentId || currentId === targetRoverId) { if (!currentId || currentId === targetRoverId) {
return { ok: true, currentId }; 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.' }; 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 = { module.exports = {
upsertRover, upsertRover,
removeRover, removeRover,
lockRover, lockRover,
setPrivateOpen,
setPrivateSafety,
getRoster, getRoster,
getRosterForSocket,
broadcastRoster, broadcastRoster,
setNightVisionState, setNightVisionState,
handleSensorFrame, handleSensorFrame,
@@ -630,6 +1146,10 @@ module.exports = {
managerEvents, managerEvents,
getRoversForSocket, getRoversForSocket,
getPrimaryRoverForSocket, getPrimaryRoverForSocket,
canSeeRover,
canRequestControl,
applyPrivateDriveSafety,
canReplayRoverId,
}; };
roleEvents.on('change', ({ socket, role }) => { roleEvents.on('change', ({ socket, role }) => {
@@ -641,7 +1161,8 @@ roleEvents.on('change', ({ socket, role }) => {
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.emit('rovers', getRoster()); tickPrivateAutoClose();
socket.emit('rovers', getRosterForSocket(socket));
if (socket.data?.role === 'spectator') { if (socket.data?.role === 'spectator') {
enableSpectator(socket); enableSpectator(socket);
} }
@@ -658,7 +1179,10 @@ io.on('connection', (socket) => {
) { ) {
throw new Error('Admins only'); 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) { if (!targetId) {
throw new Error('No rovers available'); throw new Error('No rovers available');
} }
@@ -705,13 +1229,29 @@ io.on('connection', (socket) => {
} }
function handleLockToggle({ roverId, locked } = {}, cb = () => {}) { 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' }); cb({ error: 'Not authorized' });
return; return;
} }
try { try {
lockRover(roverId, locked, { reason: 'manual' }); if (isPrivate) {
logger.info('Lock state changed', roverId, locked); 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 }); cb({ success: true });
} catch (err) { } catch (err) {
logger.warn('Lock change failed', roverId, err.message); 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 = () => {}) { function handleSubscribeAll(_, cb = () => {}) {
if (socket.data?.role !== 'spectator') { if (socket.data?.role !== 'spectator') {
cb({ error: 'Spectator role required' }); cb({ error: 'Spectator role required' });
@@ -731,7 +1293,11 @@ io.on('connection', (socket) => {
} }
logger.info('Spectator subscribing to all rovers', socket.id); logger.info('Spectator subscribing to all rovers', socket.id);
for (const record of rovers.values()) { 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 }); cb({ success: true });
} }
@@ -742,16 +1308,20 @@ io.on('connection', (socket) => {
socket.on('session:releaseControl', handleReleaseControl); socket.on('session:releaseControl', handleReleaseControl);
socket.on('lockRover', handleLockToggle); socket.on('lockRover', handleLockToggle);
socket.on('session:lockRover', handleLockToggle); socket.on('session:lockRover', handleLockToggle);
socket.on('privateSafety:set', handlePrivateSafetySet);
socket.on('session:privateSafety:set', handlePrivateSafetySet);
socket.on('subscribeAll', handleSubscribeAll); socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll); socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => { socket.on('disconnecting', () => {
logger.info('Socket disconnecting', socket.id); logger.info('Socket disconnecting', socket.id);
removeSocket(socket); removeSocket(socket);
tickPrivateAutoClose();
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id); logger.info('Socket disconnected', socket.id);
removeSocket(socket); removeSocket(socket);
tickPrivateAutoClose();
}); });
}); });
@@ -759,7 +1329,11 @@ function enableSpectator(socket) {
if (!socket?.id || spectatorSockets.has(socket.id)) return; if (!socket?.id || spectatorSockets.has(socket.id)) return;
spectatorSockets.add(socket.id); spectatorSockets.add(socket.id);
for (const record of rovers.values()) { 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); 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) => { io.on('connection', (socket) => {
socket.on('roverSnapshot:subscribe', (payload = {}, cb = () => {}) => { 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) const list = Array.isArray(payload?.ids)
? payload.ids.map(String) ? payload.ids.map(String)
: payload?.roverId || payload?.id : payload?.roverId || payload?.id
? [String(payload.roverId || payload.id)] ? [String(payload.roverId || payload.id)]
: roverManager.getRoster().map((rover) => rover.id); : visibleIds;
const uniqueIds = Array.from(new Set(list)); const uniqueIds = Array.from(new Set(list));
try { try {
if (!allowSubscribe(socket.id)) { if (!allowSubscribe(socket.id)) {
@@ -131,7 +133,7 @@ io.on('connection', (socket) => {
if (!canViewSnapshots(socket)) { if (!canViewSnapshots(socket)) {
throw new Error('Not authorized for rover snapshots'); 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))); const validIds = uniqueIds.filter((id) => rosterIds.has(String(id)));
validIds.forEach((roverId) => addSubscription(socket, roverId)); validIds.forEach((roverId) => addSubscription(socket, roverId));
validIds.forEach((roverId) => { validIds.forEach((roverId) => {
+52 -6
View File
@@ -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) { function buildSession(socket) {
const users = Array.from(io.sockets.sockets.values()) const users = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock)) .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 = const socials =
configuredSocials?.length configuredSocials?.length
? configuredSocials ? configuredSocials
@@ -70,14 +102,18 @@ function buildSession(socket) {
role: getRole(socket), role: getRole(socket),
mode: getMode(), mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)), isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
roster: roverManager.getRoster(), roster,
assignment: assignmentService.describeAssignment(socket?.id || ''), assignment: {
activeDrivers: getActiveDrivers(), ...assignment,
turnQueues: getTurnQueues(), roverId: assignmentRoverId,
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
},
activeDrivers,
turnQueues,
roomCameras: getRoomCameras(), roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(), homeAssistant: getHomeAssistantState(),
replay: getReplayState(), replay: getReplayState(),
replaySources: getReplaySources(), replaySources: getReplaySources(socket),
health: getHealthSnapshot(), health: getHealthSnapshot(),
communityGoal: getCommunityGoal(), communityGoal: getCommunityGoal(),
adminReason: getAdminReason(), adminReason: getAdminReason(),
@@ -168,6 +204,16 @@ managerEvents.on('lock', ({ roverId, locked }) => {
syncAll(); 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 }) => { managerEvents.on('driver', ({ socketId }) => {
if (!socketId) return; if (!socketId) return;
const socket = io.sockets.sockets.get(socketId); const socket = io.sockets.sockets.get(socketId);
+6
View File
@@ -162,6 +162,12 @@ app.post('/mediamtx/auth', (req, res) => {
if (!canView(socket)) { if (!canView(socket)) {
return res.status(401).end(); 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') { if (info.sourceType === 'roverMic' && action === 'publish') {
const roverId = streamInfo.baseId || streamInfo.id; const roverId = streamInfo.baseId || streamInfo.id;
if (!isVerified(socket)) { if (!isVerified(socket)) {
@@ -53,6 +53,9 @@ function canViewRover(socket, roverId) {
if (!passesMode(socket)) { if (!passesMode(socket)) {
return false; return false;
} }
if (!roverManager.canSeeRover(roverId, socket)) {
return false;
}
const role = getRole(socket); const role = getRole(socket);
if (role === 'spectator' || isAdmin(socket)) { if (role === 'spectator' || isAdmin(socket)) {
return true; return true;
+120
View File
@@ -22,6 +22,7 @@ export default function AdminPanel() {
rebootRover, rebootRover,
rebootServer, rebootServer,
setAudioLevels, setAudioLevels,
setPrivateSafety,
llmControl, llmControl,
adminLogs, adminLogs,
llmCommentaryState, llmCommentaryState,
@@ -44,11 +45,13 @@ export default function AdminPanel() {
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1, ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1, forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
}); });
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
const isAdmin = const isAdmin =
session?.role === 'admin' || session?.role === 'admin' ||
session?.role === 'lockdown' || session?.role === 'lockdown' ||
session?.role === 'lockdown-admin'; session?.role === 'lockdown-admin';
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
const currentMode = session?.mode ?? 'open'; const currentMode = session?.mode ?? 'open';
@@ -180,6 +183,41 @@ export default function AdminPanel() {
}); });
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]); }, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
useEffect(() => {
const next = {};
(roster || []).forEach((rover) => {
if (!rover?.private?.enabled) return;
next[rover.id] = {
speedLimitEnabled: Boolean(rover?.private?.safety?.speedLimitEnabled),
speedLimitMaxWheelSpeed: Number.isFinite(rover?.private?.safety?.speedLimitMaxWheelSpeed)
? rover.private.safety.speedLimitMaxWheelSpeed
: 250,
hardOvercurrentEnabled: Boolean(rover?.private?.safety?.hardOvercurrentEnabled),
overcurrentStopMs: Number.isFinite(rover?.private?.safety?.overcurrentStopMs)
? rover.private.safety.overcurrentStopMs
: 300,
hardBumpEnabled: Boolean(rover?.private?.safety?.hardBumpEnabled),
bumpBackoffSpeed: Number.isFinite(rover?.private?.safety?.bumpBackoffSpeed)
? rover.private.safety.bumpBackoffSpeed
: 250,
bumpBackoffMs: Number.isFinite(rover?.private?.safety?.bumpBackoffMs)
? rover.private.safety.bumpBackoffMs
: 350,
cliffEnabled: Boolean(rover?.private?.safety?.cliffEnabled),
cliffBackoffSpeed: Number.isFinite(rover?.private?.safety?.cliffBackoffSpeed)
? rover.private.safety.cliffBackoffSpeed
: 250,
cliffBackoffMs: Number.isFinite(rover?.private?.safety?.cliffBackoffMs)
? rover.private.safety.cliffBackoffMs
: 500,
triggerCooldownMs: Number.isFinite(rover?.private?.safety?.triggerCooldownMs)
? rover.private.safety.triggerCooldownMs
: 800,
};
});
setPrivateSafetyDrafts(next);
}, [roster]);
const lockMap = useMemo(() => { const lockMap = useMemo(() => {
const map = {}; const map = {};
roster.forEach((rover) => { roster.forEach((rover) => {
@@ -188,6 +226,23 @@ export default function AdminPanel() {
return map; return map;
}, [roster, lockStates]); }, [roster, lockStates]);
const updatePrivateSafetyDraft = (roverId, patch = {}) => {
setPrivateSafetyDrafts((current) => ({
...(current || {}),
[roverId]: { ...(current?.[roverId] || {}), ...(patch || {}) },
}));
};
const handlePrivateSafetySave = async (roverId) => {
try {
const draft = privateSafetyDrafts?.[roverId];
if (!draft) return;
await setPrivateSafety(roverId, draft);
} catch (err) {
alert(err.message);
}
};
if (!isAdmin) return null; if (!isAdmin) return null;
return ( return (
@@ -338,6 +393,71 @@ export default function AdminPanel() {
> >
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'} {rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
</button> </button>
{rover?.private?.enabled ? (
<div className="w-full rounded border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="text-slate-300">Private safety</div>
<label className="flex items-center gap-0.5">
<input
type="checkbox"
checked={Boolean(privateSafetyDrafts?.[rover.id]?.speedLimitEnabled)}
disabled={!isLockdownAdmin}
onChange={(event) =>
updatePrivateSafetyDraft(rover.id, { speedLimitEnabled: Boolean(event.target.checked) })}
/>
<span>Speed limit</span>
</label>
<input
type="number"
min="1"
max="500"
className="field-input text-xs w-20"
disabled={!isLockdownAdmin}
value={privateSafetyDrafts?.[rover.id]?.speedLimitMaxWheelSpeed ?? 250}
onChange={(event) =>
updatePrivateSafetyDraft(rover.id, {
speedLimitMaxWheelSpeed: Number(event.target.value) || 250,
})}
/>
<label className="flex items-center gap-0.5">
<input
type="checkbox"
checked={Boolean(privateSafetyDrafts?.[rover.id]?.hardOvercurrentEnabled)}
disabled={!isLockdownAdmin}
onChange={(event) =>
updatePrivateSafetyDraft(rover.id, { hardOvercurrentEnabled: Boolean(event.target.checked) })}
/>
<span>Hard overcurrent</span>
</label>
<label className="flex items-center gap-0.5">
<input
type="checkbox"
checked={Boolean(privateSafetyDrafts?.[rover.id]?.hardBumpEnabled)}
disabled={!isLockdownAdmin}
onChange={(event) =>
updatePrivateSafetyDraft(rover.id, { hardBumpEnabled: Boolean(event.target.checked) })}
/>
<span>Hard bump</span>
</label>
<label className="flex items-center gap-0.5">
<input
type="checkbox"
checked={Boolean(privateSafetyDrafts?.[rover.id]?.cliffEnabled)}
disabled={!isLockdownAdmin}
onChange={(event) =>
updatePrivateSafetyDraft(rover.id, { cliffEnabled: Boolean(event.target.checked) })}
/>
<span>Cliff safety</span>
</label>
<button
type="button"
disabled={!isLockdownAdmin}
onClick={() => handlePrivateSafetySave(rover.id)}
className="button-dark disabled:opacity-50"
>
Apply Safety
</button>
</div>
) : null}
</div> </div>
)} )}
/> />
+6 -1
View File
@@ -113,6 +113,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const isSelfNext = Boolean(selfId && nextId && nextId === selfId); const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext); const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
const locked = Boolean(rover.locked); const locked = Boolean(rover.locked);
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked'; const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
const buttonLabel = locked ? lockLabel : pending[roverId] ? '...' : 'request'; const buttonLabel = locked ? lockLabel : pending[roverId] ? '...' : 'request';
const canClickRow = canRequest && !locked && !pending[roverId]; const canClickRow = canRequest && !locked && !pending[roverId];
@@ -122,7 +123,11 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
className={classNames( className={classNames(
'surface flex flex-wrap items-start justify-between gap-0.5', 'surface flex flex-wrap items-start justify-between gap-0.5',
canClickRow && 'cursor-pointer', canClickRow && 'cursor-pointer',
locked && 'bg-red-900/40', locked
? 'bg-red-900/40'
: isPrivateOpen
? 'bg-amber-700/35 border border-amber-200/30'
: null,
)} )}
onClick={() => { onClick={() => {
if (!canClickRow) return; if (!canClickRow) return;
+11 -3
View File
@@ -30,12 +30,19 @@ export default function RoverRoster({
<p className="text-sm text-slate-500">{emptyText}</p> <p className="text-sm text-slate-500">{emptyText}</p>
) : ( ) : (
<ul className="space-y-0.5 text-sm"> <ul className="space-y-0.5 text-sm">
{roster.map((rover) => ( {roster.map((rover) => {
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
const isLocked = Boolean(rover?.locked);
return (
<li <li
key={rover.id} key={rover.id}
className={classNames( className={classNames(
'surface flex flex-wrap items-center justify-between gap-0.5', 'surface flex flex-wrap items-center justify-between gap-0.5',
rover.locked && 'bg-red-900/40', isLocked
? 'bg-red-900/40'
: isPrivateOpen
? 'bg-amber-700/35 border border-amber-200/30'
: null,
)} )}
> >
<div> <div>
@@ -54,7 +61,8 @@ export default function RoverRoster({
</div> </div>
{renderActions ? renderActions(rover) : null} {renderActions ? renderActions(rover) : null}
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</div> </div>
+3
View File
@@ -32,6 +32,7 @@ const SessionContext = createContext({
readyMicWhip: async () => {}, readyMicWhip: async () => {},
stopMicWhip: async () => {}, stopMicWhip: async () => {},
setAudioLevels: async () => {}, setAudioLevels: async () => {},
setPrivateSafety: async () => {},
llmControl: async () => {}, llmControl: async () => {},
}); });
@@ -154,6 +155,8 @@ export function SessionProvider({ children }) {
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }), readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }), stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels), setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
setPrivateSafety: (roverId, safety = {}) =>
emitWithAck('session:privateSafety:set', { roverId, safety }),
llmControl: (action, controls = {}) => llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }), emitWithAck('llm:control', { controls: { action, ...controls } }),
pushAlert: (alert) => pushAlert: (alert) =>