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"`
Horn HornConfig `json:"horn"`
NightVision NightVisionConfig `json:"nightVision"`
Private PrivateConfig `json:"private"`
}
type sensorMessage struct {
+36
View File
@@ -123,6 +123,25 @@ type AutoSideBrushConfig struct {
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 {
Name string `yaml:"name"`
Color string `yaml:"color" json:"color,omitempty"`
@@ -137,6 +156,7 @@ type Config struct {
Horn HornConfig `yaml:"horn"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
Private PrivateConfig `yaml:"private" json:"private"`
}
func LoadConfig(path string) (*Config, error) {
@@ -203,6 +223,22 @@ func LoadConfig(path string) (*Config, error) {
Enabled: true,
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 {
return nil, err
BIN
View File
Binary file not shown.
+14
View File
@@ -64,3 +64,17 @@ nightVision:
autoSideBrush:
enabled: true
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:
enabled: true
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,
Horn: c.cfg.Horn,
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)
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-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BoGitFQI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bzl2VfGU.css">
<script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
</head>
<body>
<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 }) => {
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) {
+24
View File
@@ -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);
});
+11 -4
View File
@@ -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';
+30 -6
View File
@@ -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) => {
+13 -3
View File
@@ -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(),
};
+16 -3
View File
@@ -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 })),
+22 -1
View File
@@ -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 = {
+2 -2
View File
@@ -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 });
+16 -12
View File
@@ -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 [];
}
+633 -29
View File
@@ -21,8 +21,171 @@ const DOCK_GUARD_RETRY_MS = 10 * 1000;
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
const BACKOFF_MS = 500;
const BACKOFF_SPEED = 300;
const PRIVATE_BUTTON_HOLD_MS = 3000;
const PRIVATE_AUTO_CLOSE_IDLE_MS = 30 * 60 * 1000;
const PRIVATE_AUTO_CLOSE_TICK_MS = 30000;
const SAFETY_BACKOFF_MIN = -500;
const SAFETY_BACKOFF_MAX = 500;
const backoffTimers = new Map(); // roverId -> Timeout
const dockGuardStates = new Map(); // roverId -> guard state
const privateButtonStates = new Map(); // roverId -> { pressedSince:number|null, latched:boolean }
const privateNoUsersSince = new Map(); // roverId -> timestamp|null
const privateSafetyTimers = new Map(); // roverId -> Timeout
const privateSafetyStates = new Map(); // roverId -> state
const DEFAULT_PRIVATE_SAFETY = Object.freeze({
speedLimitEnabled: false,
speedLimitMaxWheelSpeed: 250,
hardOvercurrentEnabled: false,
overcurrentStopMs: 300,
hardBumpEnabled: false,
bumpBackoffSpeed: 250,
bumpBackoffMs: 350,
cliffEnabled: false,
cliffBackoffSpeed: 250,
cliffBackoffMs: 500,
triggerCooldownMs: 800,
});
function parsePrivateMeta(meta = {}) {
const raw = meta?.private;
if (raw === true) {
return { enabled: true, safety: { ...DEFAULT_PRIVATE_SAFETY } };
}
if (!raw || typeof raw !== 'object') {
return { enabled: false, safety: { ...DEFAULT_PRIVATE_SAFETY } };
}
const safety = normalizePrivateSafety(raw.safety || {});
return {
enabled: Boolean(raw.enabled),
safety,
};
}
function clampInt(value, min, max, fallback) {
const num = Number.parseInt(value, 10);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
}
function normalizePrivateSafety(raw = {}) {
const source = raw && typeof raw === 'object' ? raw : {};
return {
speedLimitEnabled: Boolean(source.speedLimitEnabled),
speedLimitMaxWheelSpeed: clampInt(
source.speedLimitMaxWheelSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed,
),
hardOvercurrentEnabled: Boolean(source.hardOvercurrentEnabled),
overcurrentStopMs: clampInt(
source.overcurrentStopMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.overcurrentStopMs,
),
hardBumpEnabled: Boolean(source.hardBumpEnabled),
bumpBackoffSpeed: clampInt(
source.bumpBackoffSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.bumpBackoffSpeed,
),
bumpBackoffMs: clampInt(
source.bumpBackoffMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.bumpBackoffMs,
),
cliffEnabled: Boolean(source.cliffEnabled),
cliffBackoffSpeed: clampInt(
source.cliffBackoffSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.cliffBackoffSpeed,
),
cliffBackoffMs: clampInt(
source.cliffBackoffMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.cliffBackoffMs,
),
triggerCooldownMs: clampInt(
source.triggerCooldownMs,
100,
10000,
DEFAULT_PRIVATE_SAFETY.triggerCooldownMs,
),
};
}
function isPrivateRecord(record) {
return Boolean(record?.private?.enabled);
}
function isPrivateOpen(record) {
if (!isPrivateRecord(record)) return true;
return Boolean(record?.privateOpen);
}
function getPrivateSafety(record) {
if (!record) return { ...DEFAULT_PRIVATE_SAFETY };
return normalizePrivateSafety(record.privateSafety || record.private?.safety || {});
}
function shouldApplyPrivateSafety(record, socket) {
if (!isPrivateRecord(record) || !isPrivateOpen(record)) return false;
if (isLockdownAdmin(socket)) return false;
return true;
}
function isRoverVisibleToSocket(record, socket) {
if (!record) return false;
if (!isPrivateRecord(record)) return true;
if (isPrivateOpen(record)) return true;
return isLockdownAdmin(socket);
}
function getControlDenialReason(record, socket, options = {}) {
const { allowUser = false } = options;
if (!record) {
return 'Unknown rover';
}
if (!allowUser && !isAdmin(socket)) {
return 'Only admins can request control';
}
if (record.locked && !isAdmin(socket)) {
return 'Rover locked';
}
const mode = getMode();
if (!allowUser && mode === MODES.ADMIN && !isAdmin(socket)) {
return 'Admins only';
}
if (!allowUser && mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return 'Server in lockdown';
}
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return 'Server in lockdown';
}
if (!isPrivateRecord(record)) {
return null;
}
if (!isPrivateOpen(record)) {
if (!isLockdownAdmin(socket)) {
return 'Private rover is closed';
}
return null;
}
if (isLockdownAdmin(socket)) {
return null;
}
const { isVerified } = require('./verificationService');
if (!isVerified(socket)) {
return 'Private rover requires verification';
}
return null;
}
function ensureRecord(id) {
if (!rovers.has(id)) {
@@ -41,6 +204,9 @@ function ensureRecord(id) {
room: `rover:${id}`,
lastSeen: Date.now(),
lastMovementAt: Date.now(),
private: { enabled: false },
privateOpen: true,
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
});
}
return rovers.get(id);
@@ -58,6 +224,17 @@ function upsertRover(meta, ws) {
record.meta = meta;
record.ws = ws;
record.lastSeen = Date.now();
const privateMeta = parsePrivateMeta(meta);
const wasPrivate = isPrivateRecord(record);
record.private = privateMeta;
record.privateSafety = normalizePrivateSafety(privateMeta.safety);
if (privateMeta.enabled) {
if (isNew || !wasPrivate) {
record.privateOpen = false;
}
} else {
record.privateOpen = true;
}
if (record.nightVisionState == null && meta?.nightVision?.enabled) {
const ledOn = Boolean(meta.nightVision.initialOn);
record.nightVisionState = {
@@ -68,7 +245,12 @@ function upsertRover(meta, ws) {
rovers.set(id, record);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
sock?.join(record.room);
if (!sock) return;
if (isRoverVisibleToSocket(record, sock)) {
sock.join(record.room);
} else {
sock.leave(record.room);
}
});
managerEvents.emit('rover', { roverId: id, action: 'upsert', record });
if (isNew) {
@@ -83,6 +265,11 @@ function removeRover(id) {
if (!record) return;
rovers.delete(id);
stopDockGuard(id);
privateButtonStates.delete(id);
privateNoUsersSince.delete(id);
privateSafetyStates.delete(id);
clearTimeout(privateSafetyTimers.get(id));
privateSafetyTimers.delete(id);
turnService.cleanupRover(id);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
@@ -93,6 +280,27 @@ function removeRover(id) {
publishEvent({ source: 'roverManager', type: 'rover.offline', payload: { roverId: id } });
}
function sendPrivateToggleTTS(roverId, open, reason) {
const { issueCommand } = require('./commandService');
let text = open ? 'Private rover is now open.' : 'Private rover is now closed.';
if (!open && reason === 'auto_idle') {
text = 'Private rover closed due to inactivity.';
} else if (reason === 'button_hold') {
text = open ? 'Private rover opened locally.' : 'Private rover closed locally.';
}
try {
issueCommand(roverId, {
type: 'tts',
tts: {
text,
speak: true,
},
});
} catch (err) {
logger.warn('Private toggle TTS failed', { roverId, reason, error: err.message });
}
}
function lockRover(id, locked, options = {}) {
const record = rovers.get(id);
if (!record) {
@@ -141,6 +349,69 @@ function lockRover(id, locked, options = {}) {
return record.locked;
}
function setPrivateOpen(id, open, options = {}) {
const record = rovers.get(id);
if (!record) {
throw new Error('Unknown rover');
}
if (!isPrivateRecord(record)) {
throw new Error('Rover is not private');
}
const nextOpen = Boolean(open);
if (record.privateOpen === nextOpen) {
return nextOpen;
}
record.privateOpen = nextOpen;
const reason = options.reason || 'manual';
const silent = Boolean(options.silent);
if (!nextOpen) {
privateNoUsersSince.delete(id);
privateSafetyStates.delete(id);
clearTimeout(privateSafetyTimers.get(id));
privateSafetyTimers.delete(id);
}
if (!silent) {
sendAlert({
color: ALERT_COLOR,
title: nextOpen ? 'Private Rover Opened' : 'Private Rover Closed',
message: nextOpen ? `${id} opened (${reason}).` : `${id} closed (${reason}).`,
});
}
if (options.tts !== false) {
sendPrivateToggleTTS(id, nextOpen, reason);
}
publishEvent({
source: 'roverManager',
type: nextOpen ? 'rover.privateOpened' : 'rover.privateClosed',
payload: { roverId: id, reason },
});
managerEvents.emit('private', { roverId: id, open: nextOpen, reason });
broadcastRoster();
return nextOpen;
}
function setPrivateSafety(id, patch = {}, options = {}) {
const record = rovers.get(id);
if (!record) {
throw new Error('Unknown rover');
}
if (!isPrivateRecord(record)) {
throw new Error('Rover is not private');
}
const current = getPrivateSafety(record);
const next = normalizePrivateSafety({ ...current, ...(patch || {}) });
record.privateSafety = next;
const reason = options.reason || 'manual';
publishEvent({
source: 'roverManager',
type: 'rover.privateSafetyUpdated',
payload: { roverId: id, reason, safety: next },
});
managerEvents.emit('privateSafety', { roverId: id, reason, safety: next });
broadcastRoster();
return next;
}
function getRoster() {
return Array.from(rovers.values()).map((record) => ({
id: record.id,
@@ -156,14 +427,51 @@ function getRoster() {
nightVision: record.meta?.nightVision
? { ...record.meta.nightVision, state: record.nightVisionState }
: record.meta?.nightVision,
locked: record.locked,
lockReason: record.lockReason,
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason:
record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
lastSeen: record.lastSeen,
private: isPrivateRecord(record)
? {
enabled: true,
open: isPrivateOpen(record),
safety: getPrivateSafety(record),
}
: {
enabled: false,
open: true,
safety: getPrivateSafety(record),
},
}));
}
function getRosterForSocket(socket) {
return getRoster()
.filter((entry) => {
const record = rovers.get(String(entry.id));
return isRoverVisibleToSocket(record, socket);
});
}
function syncSpectatorRooms() {
spectatorSockets.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
for (const record of rovers.values()) {
if (isRoverVisibleToSocket(record, socket)) {
socket.join(record.room);
} else {
socket.leave(record.room);
}
}
});
}
function broadcastRoster() {
io.emit('rovers', getRoster());
syncSpectatorRooms();
io.sockets.sockets.forEach((socket) => {
socket.emit('rovers', getRosterForSocket(socket));
});
}
function setNightVisionState(roverId, nightVisionOn) {
@@ -247,6 +555,192 @@ function computeBatteryDisplayPercent({ charge, full, warn, urgent, percent, cap
return null;
}
function getPrivateSafetyState(roverId) {
if (!privateSafetyStates.has(roverId)) {
privateSafetyStates.set(roverId, {
blockedUntil: 0,
lastOvercurrent: false,
lastBump: false,
lastCliff: false,
});
}
return privateSafetyStates.get(roverId);
}
function stopSafetyBackoffTimer(roverId) {
clearTimeout(privateSafetyTimers.get(roverId));
privateSafetyTimers.delete(roverId);
}
function triggerSafetyAction(record, mode, options = {}) {
if (!record) return;
const roverId = record.id;
const { issueCommand, setDriveCooldown } = require('./commandService');
const now = Date.now();
const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs);
const backoffMs = clampInt(options.backoffMs, 50, 5000, 0);
const backoffSpeed = clampInt(options.backoffSpeed, 0, 500, 0);
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
} catch (err) {
logger.warn('Private safety stop failed', { roverId, mode, error: err.message });
}
stopSafetyBackoffTimer(roverId);
if (backoffMs > 0 && backoffSpeed > 0) {
const speed = Math.max(SAFETY_BACKOFF_MIN, Math.min(SAFETY_BACKOFF_MAX, -Math.abs(backoffSpeed)));
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: speed, right: speed } });
} catch (err) {
logger.warn('Private safety backoff failed', { roverId, mode, error: err.message });
}
privateSafetyTimers.set(
roverId,
setTimeout(() => {
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
} catch (err) {
logger.warn('Private safety backoff stop failed', { roverId, mode, error: err.message });
}
privateSafetyTimers.delete(roverId);
}, backoffMs),
);
}
setDriveCooldown(roverId, Math.max(cooldownMs, backoffMs));
const state = getPrivateSafetyState(roverId);
state.blockedUntil = now + Math.max(cooldownMs, backoffMs);
sendAlert({
color: ALERT_COLOR,
title: 'Private Safety',
message: `${roverId} ${mode} safety triggered.`,
});
publishEvent({
source: 'roverManager',
type: 'rover.privateSafetyTriggered',
payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed },
});
}
function evaluatePrivateSafety(record, sensors) {
if (!record || !sensors) return;
const roverId = record.id;
const state = getPrivateSafetyState(roverId);
const overcurrent = Boolean(
sensors?.wheelOvercurrents?.leftWheel ||
sensors?.wheelOvercurrents?.rightWheel ||
sensors?.wheelOvercurrents?.mainBrush ||
sensors?.wheelOvercurrents?.sideBrush,
);
const bump = Boolean(sensors?.bumpsAndWheelDrops?.bumpLeft || sensors?.bumpsAndWheelDrops?.bumpRight);
const cliff = Boolean(
sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight,
);
const currentOver = overcurrent;
const currentBump = bump;
const currentCliff = cliff;
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
state.blockedUntil = 0;
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
return;
}
const safety = getPrivateSafety(record);
const now = Date.now();
if (now < Number(state.blockedUntil || 0)) {
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
return;
}
let triggered = false;
if (safety.hardOvercurrentEnabled && currentOver && !state.lastOvercurrent) {
triggerSafetyAction(record, 'overcurrent', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.overcurrentStopMs,
backoffSpeed: 0,
});
triggered = true;
} else if (safety.hardBumpEnabled && currentBump && !state.lastBump) {
triggerSafetyAction(record, 'bump', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.bumpBackoffMs,
backoffSpeed: safety.bumpBackoffSpeed,
});
triggered = true;
} else if (safety.cliffEnabled && currentCliff && !state.lastCliff) {
triggerSafetyAction(record, 'cliff', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.cliffBackoffMs,
backoffSpeed: safety.cliffBackoffSpeed,
});
triggered = true;
}
if (!triggered) {
state.blockedUntil = 0;
}
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
}
function applyPrivateDriveSafety(roverId, socket, driveDirect = null) {
const record = rovers.get(String(roverId));
if (!record || !driveDirect || typeof driveDirect !== 'object') {
return driveDirect;
}
if (!shouldApplyPrivateSafety(record, socket)) {
return driveDirect;
}
const safety = getPrivateSafety(record);
if (!safety.speedLimitEnabled) {
return driveDirect;
}
const limit = clampInt(
safety.speedLimitMaxWheelSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed,
);
const left = clampInt(driveDirect.left, -500, 500, 0);
const right = clampInt(driveDirect.right, -500, 500, 0);
return {
...driveDirect,
left: Math.max(-limit, Math.min(limit, left)),
right: Math.max(-limit, Math.min(limit, right)),
};
}
function handlePrivateButtonHold(record, sensors) {
if (!record || !isPrivateRecord(record)) return;
const buttons = sensors?.buttons || null;
const pressed = Boolean(buttons?.spot && buttons?.clean && buttons?.dock);
const roverId = record.id;
const now = Date.now();
const state = privateButtonStates.get(roverId) || { pressedSince: null, latched: false };
if (!pressed) {
if (state.pressedSince != null || state.latched) {
privateButtonStates.set(roverId, { pressedSince: null, latched: false });
}
return;
}
if (state.pressedSince == null) {
state.pressedSince = now;
}
if (!state.latched && now - state.pressedSince >= PRIVATE_BUTTON_HOLD_MS) {
const nextOpen = !isPrivateOpen(record);
try {
setPrivateOpen(roverId, nextOpen, { reason: 'button_hold', tts: true });
} catch (err) {
logger.warn('Private button toggle failed', { roverId, error: err.message });
}
state.latched = true;
}
privateButtonStates.set(roverId, state);
}
function handleSensorFrame(roverId, frame) {
const record = rovers.get(roverId);
if (!record) return;
@@ -268,6 +762,8 @@ function handleSensorFrame(roverId, frame) {
if (bumps?.bumpLeft || bumps?.bumpRight) {
record.lastBumpAt = Date.now();
}
handlePrivateButtonHold(record, decoded);
evaluatePrivateSafety(record, decoded);
io.to(record.room).volatile.emit('sensorFrame', {
roverId,
frame,
@@ -489,21 +985,9 @@ function removeSocket(socket) {
function requestControl(roverId, socket, options = {}) {
const { force = false, allowUser = false } = options;
const record = rovers.get(roverId);
if (!record) {
throw new Error('Unknown rover');
}
if (!allowUser && !isAdmin(socket)) {
throw new Error('Only admins can request control');
}
if (record.locked && !isAdmin(socket)) {
throw new Error('Rover locked');
}
const mode = getMode();
if (!allowUser && mode === MODES.ADMIN && !isAdmin(socket)) {
throw new Error('Admins only');
}
if (!allowUser && mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
const denied = getControlDenialReason(record, socket, { allowUser });
if (denied) {
throw new Error(denied);
}
record.drivers.add(socket.id);
if (!socketToRovers.has(socket.id)) {
@@ -545,10 +1029,13 @@ function isDriver(roverId, socket) {
}
function canDrive(roverId, socket) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
return isLockdownAdmin(socket);
const record = rovers.get(roverId);
if (!record) return false;
const denied = getControlDenialReason(record, socket, { allowUser: true });
if (denied) {
return false;
}
const mode = getMode();
if (isAdmin(socket)) {
return true;
}
@@ -594,6 +1081,14 @@ function hasOtherDrivers(record, socketId) {
}
function canSwitchRover(socket, targetRoverId) {
const target = rovers.get(targetRoverId);
if (!target) {
return { ok: false, message: 'Unknown rover' };
}
const denied = getControlDenialReason(target, socket, { allowUser: true });
if (denied) {
return { ok: false, message: denied };
}
const currentId = getPrimaryRoverForSocket(socket.id);
if (!currentId || currentId === targetRoverId) {
return { ok: true, currentId };
@@ -611,11 +1106,32 @@ function canSwitchRover(socket, targetRoverId) {
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
}
function canSeeRover(roverId, socket) {
const record = rovers.get(String(roverId));
return isRoverVisibleToSocket(record, socket);
}
function canRequestControl(roverId, socket, options = {}) {
const record = rovers.get(String(roverId));
const denied = getControlDenialReason(record, socket, options);
return { ok: !denied, reason: denied || null };
}
function canReplayRoverId(roverId) {
const record = rovers.get(String(roverId));
if (!record) return false;
if (!isPrivateRecord(record)) return true;
return isPrivateOpen(record);
}
module.exports = {
upsertRover,
removeRover,
lockRover,
setPrivateOpen,
setPrivateSafety,
getRoster,
getRosterForSocket,
broadcastRoster,
setNightVisionState,
handleSensorFrame,
@@ -630,6 +1146,10 @@ module.exports = {
managerEvents,
getRoversForSocket,
getPrimaryRoverForSocket,
canSeeRover,
canRequestControl,
applyPrivateDriveSafety,
canReplayRoverId,
};
roleEvents.on('change', ({ socket, role }) => {
@@ -641,7 +1161,8 @@ roleEvents.on('change', ({ socket, role }) => {
});
io.on('connection', (socket) => {
socket.emit('rovers', getRoster());
tickPrivateAutoClose();
socket.emit('rovers', getRosterForSocket(socket));
if (socket.data?.role === 'spectator') {
enableSpectator(socket);
}
@@ -658,7 +1179,10 @@ io.on('connection', (socket) => {
) {
throw new Error('Admins only');
}
const targetId = roverId || Array.from(rovers.keys())[0];
const fallbackTargetId = Array.from(rovers.keys()).find(
(id) => canRequestControl(id, socket, { allowUser: true }).ok,
);
const targetId = roverId || fallbackTargetId;
if (!targetId) {
throw new Error('No rovers available');
}
@@ -705,13 +1229,29 @@ io.on('connection', (socket) => {
}
function handleLockToggle({ roverId, locked } = {}, cb = () => {}) {
if (!isAdmin(socket)) {
const record = rovers.get(roverId);
if (!record) {
cb({ error: 'Unknown rover' });
return;
}
const isPrivate = isPrivateRecord(record);
if (isPrivate && !isLockdownAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
if (!isPrivate && !isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
lockRover(roverId, locked, { reason: 'manual' });
logger.info('Lock state changed', roverId, locked);
if (isPrivate) {
const open = !Boolean(locked);
setPrivateOpen(roverId, open, { reason: 'manual' });
logger.info('Private state changed', roverId, { open });
} else {
lockRover(roverId, locked, { reason: 'manual' });
logger.info('Lock state changed', roverId, locked);
}
cb({ success: true });
} catch (err) {
logger.warn('Lock change failed', roverId, err.message);
@@ -720,6 +1260,28 @@ io.on('connection', (socket) => {
}
}
function handlePrivateSafetySet({ roverId, safety } = {}, cb = () => {}) {
const record = rovers.get(roverId);
if (!record) {
cb({ error: 'Unknown rover' });
return;
}
if (!isPrivateRecord(record)) {
cb({ error: 'Rover is not private' });
return;
}
if (!isLockdownAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const next = setPrivateSafety(roverId, safety || {}, { reason: 'manual' });
cb({ success: true, safety: next });
} catch (err) {
cb({ error: err.message });
}
}
function handleSubscribeAll(_, cb = () => {}) {
if (socket.data?.role !== 'spectator') {
cb({ error: 'Spectator role required' });
@@ -731,7 +1293,11 @@ io.on('connection', (socket) => {
}
logger.info('Spectator subscribing to all rovers', socket.id);
for (const record of rovers.values()) {
socket.join(record.room);
if (isRoverVisibleToSocket(record, socket)) {
socket.join(record.room);
} else {
socket.leave(record.room);
}
}
cb({ success: true });
}
@@ -742,16 +1308,20 @@ io.on('connection', (socket) => {
socket.on('session:releaseControl', handleReleaseControl);
socket.on('lockRover', handleLockToggle);
socket.on('session:lockRover', handleLockToggle);
socket.on('privateSafety:set', handlePrivateSafetySet);
socket.on('session:privateSafety:set', handlePrivateSafetySet);
socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => {
logger.info('Socket disconnecting', socket.id);
removeSocket(socket);
tickPrivateAutoClose();
});
socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id);
removeSocket(socket);
tickPrivateAutoClose();
});
});
@@ -759,7 +1329,11 @@ function enableSpectator(socket) {
if (!socket?.id || spectatorSockets.has(socket.id)) return;
spectatorSockets.add(socket.id);
for (const record of rovers.values()) {
socket.join(record.room);
if (isRoverVisibleToSocket(record, socket)) {
socket.join(record.room);
} else {
socket.leave(record.room);
}
}
}
@@ -770,3 +1344,33 @@ function disableSpectator(socket) {
socket.leave(record.room);
}
}
function tickPrivateAutoClose() {
const now = Date.now();
const onlineCount = io.sockets.sockets.size;
for (const record of rovers.values()) {
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
privateNoUsersSince.delete(record.id);
continue;
}
if (onlineCount > 0) {
privateNoUsersSince.delete(record.id);
continue;
}
const since = privateNoUsersSince.get(record.id) || now;
if (!privateNoUsersSince.has(record.id)) {
privateNoUsersSince.set(record.id, since);
continue;
}
if (now - since >= PRIVATE_AUTO_CLOSE_IDLE_MS) {
try {
setPrivateOpen(record.id, false, { reason: 'auto_idle', tts: true });
} catch (err) {
logger.warn('Private auto-close failed', { roverId: record.id, error: err.message });
}
privateNoUsersSince.delete(record.id);
}
}
}
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
@@ -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) => {
+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) {
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);
+6
View File
@@ -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;
+120
View File
@@ -22,6 +22,7 @@ export default function AdminPanel() {
rebootRover,
rebootServer,
setAudioLevels,
setPrivateSafety,
llmControl,
adminLogs,
llmCommentaryState,
@@ -44,11 +45,13 @@ export default function AdminPanel() {
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
});
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
const isAdmin =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
const currentMode = session?.mode ?? 'open';
@@ -180,6 +183,41 @@ export default function AdminPanel() {
});
}, [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 map = {};
roster.forEach((rover) => {
@@ -188,6 +226,23 @@ export default function AdminPanel() {
return map;
}, [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;
return (
@@ -338,6 +393,71 @@ export default function AdminPanel() {
>
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
</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>
)}
/>
+6 -1
View File
@@ -113,6 +113,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
const locked = Boolean(rover.locked);
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
const buttonLabel = locked ? lockLabel : pending[roverId] ? '...' : 'request';
const canClickRow = canRequest && !locked && !pending[roverId];
@@ -122,7 +123,11 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
className={classNames(
'surface flex flex-wrap items-start justify-between gap-0.5',
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={() => {
if (!canClickRow) return;
+11 -3
View File
@@ -30,12 +30,19 @@ export default function RoverRoster({
<p className="text-sm text-slate-500">{emptyText}</p>
) : (
<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
key={rover.id}
className={classNames(
'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>
@@ -54,7 +61,8 @@ export default function RoverRoster({
</div>
{renderActions ? renderActions(rover) : null}
</li>
))}
);
})}
</ul>
)}
</div>
+3
View File
@@ -32,6 +32,7 @@ const SessionContext = createContext({
readyMicWhip: async () => {},
stopMicWhip: async () => {},
setAudioLevels: async () => {},
setPrivateSafety: async () => {},
llmControl: async () => {},
});
@@ -154,6 +155,8 @@ export function SessionProvider({ children }) {
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
setPrivateSafety: (roverId, safety = {}) =>
emitWithAck('session:privateSafety:set', { roverId, safety }),
llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }),
pushAlert: (alert) =>