Merge pull request #24 from legop3/HELP

Help
This commit is contained in:
legop3
2026-09-12 18:34:35 -04:00
committed by GitHub
30 changed files with 848 additions and 88 deletions
+3
View File
@@ -12,6 +12,9 @@ require('./src/services/eventBus');
require('./src/services/modeManager');
require('./src/services/lockdownGuard');
require('./src/services/roverManager');
// Help monitoring subscribes to roverManager telemetry before assignment and
// session services begin consuming the resulting roster state.
require('./src/services/roverHelpService');
require('./src/services/commandService');
require('./src/services/roverConnectionService');
require('./src/services/assignmentService');
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
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
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-CNVNbsvk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5TEaoXl.css">
<script type="module" crossorigin src="/assets/index-C2LCvBX6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CqaMtmWx.css">
</head>
<body>
<div id="root"></div>
+10 -1
View File
@@ -97,6 +97,12 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
}
});
roverManager.managerEvents.on('help', ({ needsHelp }) => {
// Entering HELP affects only future automatic placement. When HELP clears,
// retry people who were waiting because every healthy rover was unavailable.
if (!needsHelp) reassignWaiting();
});
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
if (action === 'removed') {
/*
@@ -238,7 +244,10 @@ function pickRover(socket, options = {}) {
return null;
}
const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
if (!rover || rover.locked) return false;
// HELP removes a rover only from automatic placement. Existing drivers are
// not displaced, and explicit requestControl calls retain their normal
// access policy so a person can deliberately take control to rescue it.
if (!rover || rover.locked || rover.needsHelp) return false;
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
if (!access.ok) return false;
return true;
@@ -6,7 +6,7 @@ const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbe
function createBusEventHandler(deps) {
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
let skippedFirstModeAnnouncement = false;
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
@@ -85,6 +85,23 @@ function createBusEventHandler(deps) {
case 'rover.dockGuard':
announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` });
break;
case 'rover.helpNeeded':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xef4444,
title: 'Rover Needs Help',
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'}: ${payload?.reason || 'a sustained rover fault was detected'}.`,
});
break;
case 'rover.helpCleared':
announce({
channelId: channels.adminAlerts,
color: 0x4caf50,
title: 'Rover Help Cleared',
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'} no longer needs help.`,
});
break;
case 'battery.warn':
announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xf0b651, content: buildBatteryCaption(type, rovers.get(payload?.roverId || 'unknown')), embeds: [buildBatteryStatusEmbed({ color: 0xf0b651, records: Array.from(rovers.values()) })] });
break;
@@ -0,0 +1,140 @@
// Rover Help Horn Notifier
// Purpose: Repeats a short, disruptive locator chirp while a rover needs help.
// Scope: Uses the existing server-to-roverd horn start/stop protocol without changing roverd.
const HELP_HORN_FREQUENCY_HZ = 2000;
const HELP_HORN_DURATION_MS = 250;
const HELP_HORN_INTERVAL_MS = 5 * 1000;
const HELP_ROOMBA_NOTE = 83;
const HELP_ROOMBA_NOTE_DURATION = 16;
function createHelpHornNotifier({
getRover,
issueCommand,
logger,
setIntervalFn = setInterval,
clearIntervalFn = clearInterval,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
} = {}) {
const intervals = new Map();
const stopTimers = new Map();
function stopPendingHorn(roverId) {
const id = String(roverId);
const stopTimer = stopTimers.get(id);
if (stopTimer != null) clearTimeoutFn(stopTimer);
stopTimers.delete(id);
// The Roomba song is self-terminating. With no pending external-horn stop,
// there is no persistent sound owned by this notifier that needs cleanup.
if (stopTimer == null) return;
// Always send a final stop during cleanup. This ensures HELP clearing in
// the middle of a 250 ms chirp silences it immediately instead of waiting
// for a timeout that was just cancelled.
const record = getRover?.(id);
if (!record?.ws) return;
try {
issueCommand(id, { type: 'horn', horn: { action: 'stop' } });
} catch (err) {
logger?.warn?.('Failed to stop rover help horn', { roverId: id, error: err.message });
}
}
function chirp(roverId) {
const id = String(roverId);
const record = getRover?.(id);
if (!record?.ws) return false;
let sounded = false;
let externalHornStarted = false;
if (record.meta?.horn?.enabled) {
try {
issueCommand(id, {
type: 'horn',
horn: {
action: 'start',
waveform: 'saw',
freqs: [HELP_HORN_FREQUENCY_HZ],
},
});
sounded = true;
externalHornStarted = true;
} catch (err) {
logger?.warn?.('Failed to start rover help horn', { roverId: id, error: err.message });
}
}
try {
// Roomba 600-series songs use MIDI notes and 1/64-second durations.
// Note 83 is approximately 987.8 Hz: one octave below the closest MIDI
// pitch to the external 2000 Hz horn. Duration 16 matches its 250 ms pulse.
issueCommand(id, {
type: 'song',
song: {
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
});
sounded = true;
} catch (err) {
logger?.warn?.('Failed to play rover help song', { roverId: id, error: err.message });
}
if (externalHornStarted) {
// There can be only one pending automatic stop for a rover. Replacing an
// unexpected stale timer keeps the external pulse duration bounded; the
// independently issued Roomba song always ends itself.
const previousStop = stopTimers.get(id);
if (previousStop != null) clearTimeoutFn(previousStop);
stopTimers.set(
id,
setTimeoutFn(() => {
stopTimers.delete(id);
const current = getRover?.(id);
if (!current?.ws) return;
try {
issueCommand(id, { type: 'horn', horn: { action: 'stop' } });
} catch (err) {
logger?.warn?.('Failed to finish rover help chirp', { roverId: id, error: err.message });
}
}, HELP_HORN_DURATION_MS),
);
}
return sounded;
}
function start(roverId) {
const id = String(roverId);
if (intervals.has(id)) return;
const record = getRover?.(id);
if (!record?.ws) return;
// Sound immediately so a newly detected rover can be located without
// waiting through the first five-second interval.
chirp(id);
intervals.set(id, setIntervalFn(() => chirp(id), HELP_HORN_INTERVAL_MS));
}
function stop(roverId) {
const id = String(roverId);
const interval = intervals.get(id);
if (interval != null) clearIntervalFn(interval);
intervals.delete(id);
stopPendingHorn(id);
}
return {
chirp,
start,
stop,
};
}
module.exports = {
HELP_HORN_DURATION_MS,
HELP_HORN_FREQUENCY_HZ,
HELP_HORN_INTERVAL_MS,
HELP_ROOMBA_NOTE,
HELP_ROOMBA_NOTE_DURATION,
createHelpHornNotifier,
};
@@ -0,0 +1,95 @@
// Rover Help Horn Notifier Tests
// Purpose: Verifies the exact chirp payload, cadence, duration, and cleanup without real timers.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
HELP_HORN_DURATION_MS,
HELP_HORN_FREQUENCY_HZ,
HELP_HORN_INTERVAL_MS,
HELP_ROOMBA_NOTE,
HELP_ROOMBA_NOTE_DURATION,
createHelpHornNotifier,
} = require('./hornNotifier');
function createHarness({ enabled = true } = {}) {
const commands = [];
const intervals = new Map();
const timeouts = new Map();
const clearedIntervals = [];
const clearedTimeouts = [];
let nextTimerId = 1;
const notifier = createHelpHornNotifier({
getRover: () => ({ ws: {}, meta: { horn: { enabled } } }),
issueCommand: (roverId, payload) => commands.push({ roverId, payload }),
setIntervalFn: (callback, ms) => {
const id = nextTimerId++;
intervals.set(id, { callback, ms });
return id;
},
clearIntervalFn: (id) => clearedIntervals.push(id),
setTimeoutFn: (callback, ms) => {
const id = nextTimerId++;
timeouts.set(id, { callback, ms });
return id;
},
clearTimeoutFn: (id) => clearedTimeouts.push(id),
});
return { clearedIntervals, clearedTimeouts, commands, intervals, notifier, timeouts };
}
test('starts matching external and Roomba chirps and schedules the agreed cadence', () => {
const harness = createHarness();
harness.notifier.start('red');
assert.deepEqual(harness.commands, [
{
roverId: 'red',
payload: {
type: 'horn',
horn: { action: 'start', waveform: 'saw', freqs: [HELP_HORN_FREQUENCY_HZ] },
},
},
{
roverId: 'red',
payload: {
type: 'song',
song: {
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
},
},
]);
assert.equal(Array.from(harness.intervals.values())[0].ms, HELP_HORN_INTERVAL_MS);
assert.equal(Array.from(harness.timeouts.values())[0].ms, HELP_HORN_DURATION_MS);
Array.from(harness.timeouts.values())[0].callback();
assert.equal(harness.commands.at(-1).payload.horn.action, 'stop');
});
test('stop cancels cadence and pending pulse before issuing a final horn stop', () => {
const harness = createHarness();
harness.notifier.start('blue');
const intervalId = Array.from(harness.intervals.keys())[0];
const timeoutId = Array.from(harness.timeouts.keys())[0];
harness.notifier.stop('blue');
assert.deepEqual(harness.clearedIntervals, [intervalId]);
assert.deepEqual(harness.clearedTimeouts, [timeoutId]);
assert.equal(harness.commands.at(-1).payload.horn.action, 'stop');
});
test('still schedules the Roomba song when the external horn is disabled', () => {
const harness = createHarness({ enabled: false });
harness.notifier.start('green');
assert.deepEqual(harness.commands, [{
roverId: 'green',
payload: {
type: 'song',
song: {
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
},
}]);
assert.equal(harness.intervals.size, 1);
assert.equal(harness.timeouts.size, 0);
});
@@ -0,0 +1,71 @@
// Rover Help Service
// Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state.
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
const roverManager = require('../roverManager');
const { sendAlert } = require('../alertService');
const { publishEvent } = require('../eventBus');
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
const { createHelpHornNotifier } = require('./hornNotifier');
const HELP_ALERT_COLOR = '#ef4444';
const hornNotifier = createHelpHornNotifier({
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
// commandService imports roverManager, so resolving it only when a chirp is
// actually issued avoids turning server startup order into a circular module
// dependency while retaining the established command transport.
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
logger: require('../../globals/logger').child('roverHelpService'),
});
const monitor = createRoverHelpMonitor({
onChange({ roverId, needsHelp, addedReason, reasons }) {
const record = roverManager.rovers.get(String(roverId));
if (!record) return;
const wasNeedingHelp = Boolean(record.needsHelp);
roverManager.setNeedsHelp(roverId, needsHelp);
if (!wasNeedingHelp && needsHelp) {
hornNotifier.start(roverId);
const reason = REASON_LABELS[addedReason] || 'a sustained rover fault was detected';
sendAlert({
color: HELP_ALERT_COLOR,
title: 'Rover Needs Help',
message: `${record.meta?.name || roverId}: ${reason}.`,
});
// Discord owns presentation of this event. The UI intentionally receives
// only the roster boolean, keeping every HELP overlay free of reason text.
publishEvent({
source: 'roverHelpService',
type: 'rover.helpNeeded',
payload: { roverId, roverName: record.meta?.name || roverId, reason, reasons },
});
} else if (wasNeedingHelp && !needsHelp) {
hornNotifier.stop(roverId);
publishEvent({
source: 'roverHelpService',
type: 'rover.helpCleared',
payload: { roverId, roverName: record.meta?.name || roverId },
});
}
},
});
roverManager.managerEvents.on('sensor', ({ roverId, sensors }) => {
monitor.handleSensor(roverId, sensors);
});
roverManager.managerEvents.on('dockGuard', (event) => {
monitor.handleDockGuard(event);
});
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
// A reconnect gets a fresh record and fresh persistence timers; stale sensor
// history from a disconnected chassis must never immediately restore HELP.
if (action === 'removed') {
hornNotifier.stop(roverId);
monitor.removeRover(roverId);
}
});
module.exports = { hornNotifier, monitor };
@@ -0,0 +1,177 @@
// Rover Help Monitor
// Purpose: Converts sustained 600-series Roomba sensor conditions into one help state.
// Scope: Owns timing and reason state without performing roster fanout, alerts, or Discord I/O.
const WHEEL_DROP_HELP_MS = 15 * 60 * 1000;
const CLIFF_HELP_MS = 10 * 60 * 1000;
const DOCK_GUARD_HELP_MS = 15 * 60 * 1000;
const REASON_LABELS = Object.freeze({
wheelDrop: 'a wheel-drop sensor remained active for 15 minutes',
cliff: 'the same cliff-sensor pattern remained active for 10 minutes',
docking: 'automatic docking remained active for 15 minutes',
});
function cliffPattern(sensors) {
// A four-bit pattern distinguishes one continuously held physical situation
// from a rover encountering different edges. Zero means no active cliff and
// therefore cannot begin or retain a cliff-help timer.
return [
sensors?.cliffLeft,
sensors?.cliffFrontLeft,
sensors?.cliffFrontRight,
sensors?.cliffRight,
].reduce((pattern, active, index) => pattern | (active ? 1 << index : 0), 0);
}
function createRoverHelpMonitor({ now = () => Date.now(), onChange = () => {} } = {}) {
const states = new Map();
function ensureState(roverId) {
const id = String(roverId);
if (!states.has(id)) {
states.set(id, {
wheelDropSince: null,
cliffPattern: 0,
cliffPatternSince: null,
dockGuardSince: null,
dockGuardSawPassive: false,
reasons: new Set(),
});
}
return states.get(id);
}
function updateReason(roverId, state, reason, active) {
const hadReason = state.reasons.has(reason);
if (active === hadReason) return;
if (active) state.reasons.add(reason);
else state.reasons.delete(reason);
// Notify on every reason-set change so the integration can update the
// aggregate flag correctly when one condition clears but another remains.
onChange({
roverId: String(roverId),
needsHelp: state.reasons.size > 0,
addedReason: active ? reason : null,
removedReason: active ? null : reason,
reasons: Array.from(state.reasons),
});
}
function handleSensor(roverId, sensors) {
if (!roverId || !sensors) return;
const state = ensureState(roverId);
const timestamp = now();
const docked = Boolean(sensors?.chargingSources?.homeBase);
if (docked) {
// A 600-series Roomba can legitimately rest on the dock with wheel-drop
// or cliff bits held by its physical position and the nearby surface.
// Home-base contact is therefore a stronger signal than every monitored
// fault here: reset all persistence history and do not let time spent
// docked contribute toward a later HELP after it leaves the base.
state.wheelDropSince = null;
state.cliffPattern = 0;
state.cliffPatternSince = null;
state.dockGuardSince = null;
state.dockGuardSawPassive = false;
updateReason(roverId, state, 'wheelDrop', false);
updateReason(roverId, state, 'cliff', false);
updateReason(roverId, state, 'docking', false);
return;
}
const wheelDrop = Boolean(
sensors?.bumpsAndWheelDrops?.wheelDropLeft || sensors?.bumpsAndWheelDrops?.wheelDropRight,
);
if (wheelDrop) {
if (state.wheelDropSince == null) state.wheelDropSince = timestamp;
} else {
state.wheelDropSince = null;
}
updateReason(
roverId,
state,
'wheelDrop',
state.wheelDropSince != null && timestamp - state.wheelDropSince >= WHEEL_DROP_HELP_MS,
);
const nextCliffPattern = cliffPattern(sensors);
if (!nextCliffPattern) {
state.cliffPattern = 0;
state.cliffPatternSince = null;
} else if (nextCliffPattern !== state.cliffPattern) {
// Any changed combination is new evidence on a non-mapping Roomba, not
// proof that its chassis translated. Restart only the persistence timer;
// encoder counts are deliberately not consulted anywhere in this monitor.
state.cliffPattern = nextCliffPattern;
state.cliffPatternSince = timestamp;
}
updateReason(
roverId,
state,
'cliff',
state.cliffPatternSince != null && timestamp - state.cliffPatternSince >= CLIFF_HELP_MS,
);
if (state.dockGuardSince != null) {
const oiMode = sensors?.oiMode?.label || null;
if (oiMode === 'passive') state.dockGuardSawPassive = true;
if (state.dockGuardSawPassive && oiMode && oiMode !== 'passive') {
// Dock guard itself stops as soon as the 600-series wheels begin their
// autonomous seek motion. Continue timing that seek after the guard
// interval ends, and clear only on docking or a confirmed exit from the
// passive OI mode used by opcode 143.
state.dockGuardSince = null;
state.dockGuardSawPassive = false;
}
}
updateReason(
roverId,
state,
'docking',
state.dockGuardSince != null && timestamp - state.dockGuardSince >= DOCK_GUARD_HELP_MS,
);
}
function handleDockGuard({ roverId, active, startedAt = null } = {}) {
if (!roverId) return;
const state = ensureState(roverId);
if (active) {
// Prefer roverManager's authoritative start time. The fallback keeps the
// monitor deterministic if an event source omits it in a future caller.
state.dockGuardSince = Number.isFinite(Number(startedAt)) ? Number(startedAt) : now();
state.dockGuardSawPassive = false;
return;
}
// Before passive mode is observed, a stopped guard means docking never
// began. Once passive has been seen, wheel activity stops the guard even
// though the Roomba is still autonomously seeking its dock, so sensor mode
// and charging state become the authoritative completion signals instead.
if (!state.dockGuardSawPassive) {
state.dockGuardSince = null;
updateReason(roverId, state, 'docking', false);
}
}
function removeRover(roverId) {
states.delete(String(roverId));
}
return {
handleSensor,
handleDockGuard,
removeRover,
};
}
module.exports = {
CLIFF_HELP_MS,
DOCK_GUARD_HELP_MS,
REASON_LABELS,
WHEEL_DROP_HELP_MS,
cliffPattern,
createRoverHelpMonitor,
};
@@ -0,0 +1,137 @@
// Rover Help Monitor Tests
// Purpose: Locks down sustained-condition timing without real timers or hardware.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
CLIFF_HELP_MS,
DOCK_GUARD_HELP_MS,
WHEEL_DROP_HELP_MS,
createRoverHelpMonitor,
} = require('./monitor');
function createHarness() {
let timestamp = 1_000;
const changes = [];
const monitor = createRoverHelpMonitor({ now: () => timestamp, onChange: (change) => changes.push(change) });
return {
changes,
monitor,
advance(ms) {
timestamp += ms;
},
now() {
return timestamp;
},
};
}
test('requires a continuous wheel drop and clears help when it releases', () => {
const harness = createHarness();
const dropped = { bumpsAndWheelDrops: { wheelDropLeft: true } };
harness.monitor.handleSensor('red', dropped);
harness.advance(WHEEL_DROP_HELP_MS - 1);
harness.monitor.handleSensor('red', dropped);
assert.equal(harness.changes.length, 0);
harness.advance(1);
harness.monitor.handleSensor('red', dropped);
assert.equal(harness.changes.at(-1).needsHelp, true);
assert.equal(harness.changes.at(-1).addedReason, 'wheelDrop');
harness.monitor.handleSensor('red', { bumpsAndWheelDrops: {} });
assert.equal(harness.changes.at(-1).needsHelp, false);
});
test('a changed cliff combination restarts the ten minute timer', () => {
const harness = createHarness();
harness.monitor.handleSensor('blue', { cliffLeft: true });
harness.advance(CLIFF_HELP_MS - 1);
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
harness.advance(1);
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
assert.equal(harness.changes.length, 0);
harness.advance(CLIFF_HELP_MS - 1);
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
assert.equal(harness.changes.at(-1).addedReason, 'cliff');
});
test('dock guard uses elapsed active time and clears when the guard stops', () => {
const harness = createHarness();
harness.monitor.handleDockGuard({ roverId: 'green', active: true, startedAt: harness.now() });
harness.advance(DOCK_GUARD_HELP_MS);
harness.monitor.handleSensor('green', {});
assert.equal(harness.changes.at(-1).addedReason, 'docking');
harness.monitor.handleDockGuard({ roverId: 'green', active: false });
assert.equal(harness.changes.at(-1).needsHelp, false);
});
test('autonomous passive docking remains timed after wheel motion stops dock guard', () => {
const harness = createHarness();
harness.monitor.handleDockGuard({ roverId: 'yellow', active: true, startedAt: harness.now() });
harness.monitor.handleSensor('yellow', { oiMode: { label: 'passive' }, chargingSources: {} });
harness.monitor.handleDockGuard({ roverId: 'yellow', active: false });
harness.advance(DOCK_GUARD_HELP_MS);
harness.monitor.handleSensor('yellow', { oiMode: { label: 'passive' }, chargingSources: {} });
assert.equal(harness.changes.at(-1).addedReason, 'docking');
harness.monitor.handleSensor('yellow', {
oiMode: { label: 'passive' },
chargingSources: { homeBase: true },
});
assert.equal(harness.changes.at(-1).needsHelp, false);
});
test('clearing one reason retains help while another reason remains', () => {
const harness = createHarness();
const both = { bumpsAndWheelDrops: { wheelDropRight: true }, cliffRight: true };
harness.monitor.handleSensor('orange', both);
// Advance through the longer threshold so both independently sustained
// conditions are active before exercising aggregate clearing behavior.
harness.advance(WHEEL_DROP_HELP_MS);
harness.monitor.handleSensor('orange', both);
assert.deepEqual(harness.changes.at(-1).reasons.sort(), ['cliff', 'wheelDrop']);
harness.monitor.handleSensor('orange', { cliffRight: true });
assert.equal(harness.changes.at(-1).needsHelp, true);
assert.deepEqual(harness.changes.at(-1).reasons, ['cliff']);
});
test('docked sensor conditions never accumulate help time', () => {
const harness = createHarness();
const dockedFaults = {
chargingSources: { homeBase: true },
bumpsAndWheelDrops: { wheelDropLeft: true },
cliffLeft: true,
};
harness.monitor.handleSensor('purple', dockedFaults);
harness.advance(WHEEL_DROP_HELP_MS + CLIFF_HELP_MS);
harness.monitor.handleSensor('purple', dockedFaults);
assert.equal(harness.changes.length, 0);
// Leaving the dock begins fresh timers instead of inheriting the long period
// during which those same physical bits were harmlessly held at home base.
harness.monitor.handleSensor('purple', {
chargingSources: {},
bumpsAndWheelDrops: { wheelDropLeft: true },
cliffLeft: true,
});
assert.equal(harness.changes.length, 0);
});
test('docking clears every active help reason', () => {
const harness = createHarness();
const faults = { bumpsAndWheelDrops: { wheelDropRight: true }, cliffRight: true };
harness.monitor.handleSensor('silver', faults);
harness.advance(WHEEL_DROP_HELP_MS);
harness.monitor.handleSensor('silver', faults);
assert.equal(harness.changes.at(-1).needsHelp, true);
harness.monitor.handleSensor('silver', {
...faults,
chargingSources: { homeBase: true },
});
assert.equal(harness.changes.at(-1).needsHelp, false);
assert.deepEqual(harness.changes.at(-1).reasons, []);
});
@@ -133,6 +133,7 @@ const {
getRoster,
getRosterForSocket,
broadcastRoster,
setNeedsHelp,
setToggleState,
handleHostStats,
canSeeRover,
@@ -281,6 +282,7 @@ module.exports = {
getRoster,
getRosterForSocket,
broadcastRoster,
setNeedsHelp,
setToggleState,
handleHostStats,
handleSensorFrame,
@@ -46,6 +46,10 @@ function createRosterLifecycle(deps) {
room: `rover:${id}`,
lastSeen: Date.now(),
lastMovementAt: Date.now(),
// Help state belongs to the live rover record so every roster consumer
// sees one server-authoritative answer. The monitoring service owns why
// the flag changes; roverManager only owns publishing the roster field.
needsHelp: false,
private: { enabled: false },
privateOpen: true,
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
@@ -252,6 +256,7 @@ function createRosterLifecycle(deps) {
: record.meta?.laser,
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
needsHelp: Boolean(record.needsHelp),
lastSeen: record.lastSeen,
private: isPrivateRecord(record)
? { enabled: true, open: isPrivateOpen(record), safety: getPrivateSafety(record) }
@@ -299,6 +304,21 @@ function createRosterLifecycle(deps) {
managerEvents.emit('rover', { roverId, action: device, record });
}
function setNeedsHelp(roverId, needsHelp) {
const record = rovers.get(String(roverId));
if (!record) return false;
const next = Boolean(needsHelp);
if (record.needsHelp === next) return false;
// Emit both roster fanout forms used by the application. The lightweight
// `rovers` event updates direct roster listeners immediately, while the
// manager event asks sessionService to rebuild complete session snapshots.
record.needsHelp = next;
broadcastRoster();
managerEvents.emit('help', { roverId: record.id, needsHelp: next });
return true;
}
function handleHostStats(roverId, msg = {}) {
const record = rovers.get(roverId);
if (!record) return;
@@ -345,6 +365,7 @@ function createRosterLifecycle(deps) {
getRosterForSocket,
syncSpectatorRooms,
broadcastRoster,
setNeedsHelp,
setToggleState,
handleHostStats,
canSeeRover,
@@ -329,6 +329,7 @@ function createSensorPipeline(deps) {
function stopDockGuard(roverId) {
const state = dockGuardStates.get(roverId);
if (!state) return;
const wasActive = state.active;
if (state.timer) clearInterval(state.timer);
state.active = false;
state.reason = null;
@@ -336,6 +337,11 @@ function createSensorPipeline(deps) {
state.timer = null;
state.idleUndockedSince = null;
state.passiveUndockedSince = null;
// The help monitor must clear its docking timer at the same ownership
// boundary that stops dock guard. Inferring this from OI mode would be
// incorrect because a 600-series rover can enter passive mode for reasons
// other than the server's automatic docking workflow.
if (wasActive) managerEvents.emit('dockGuard', { roverId, active: false });
}
function attemptDockGuard(roverId) {
@@ -360,6 +366,12 @@ function createSensorPipeline(deps) {
state.active = true;
state.reason = reason;
state.startedAt = Date.now();
managerEvents.emit('dockGuard', {
roverId: record.id,
active: true,
reason,
startedAt: state.startedAt,
});
const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked';
sendAlert({
color: ALERT_COLOR,
@@ -355,6 +355,13 @@ managerEvents.on('privateSafety', ({ roverId }) => {
syncAll();
});
managerEvents.on('help', ({ roverId, needsHelp }) => {
// HELP is roster state used by several passive UI routes, so every connected
// socket must receive the transition rather than only the rover's drivers.
logger.info('Rover help state changed', roverId, needsHelp);
syncAll();
});
privateRoverAccessRequestEvents.on('change', (event = {}) => {
logger.info('Private rover access request state changed', event.reason || 'unknown');
syncAll();
@@ -0,0 +1,82 @@
// Auto Fit Text
// Purpose: Sizes one unwrapped label to the largest font that fits its container.
// Scope: Supports the existing width-only labels and full-box overlays from one shared implementation.
import { useLayoutEffect, useRef, useState } from 'react';
export default function AutoFitText({
children,
className = '',
containerClassName = '',
maxSize = 1000,
minSize = 14,
fitHeight = false,
style = undefined,
}) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let animationFrame = null;
const fit = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || (fitHeight && !height)) {
scheduleFit();
return;
}
// Binary search avoids stepping through hundreds of possible font sizes.
// Overlay labels test both axes; ordinary rover labels preserve their
// previous width-only behavior so this shared move changes no layout.
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
textEl.style.fontSize = `${middle}px`;
const fitsWidth = textEl.scrollWidth <= width;
const fitsHeight = !fitHeight || textEl.scrollHeight <= height;
if (fitsWidth && fitsHeight) {
best = middle;
low = middle + 1;
} else {
high = middle - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(fit);
};
scheduleFit();
const observer = new ResizeObserver(scheduleFit);
observer.observe(container);
return () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [children, fitHeight, maxSize, minSize]);
return (
<div
ref={containerRef}
className={`${fitHeight ? 'flex h-full items-center justify-center' : ''} w-full min-w-0 ${containerClassName}`}
>
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: fitHeight ? 1 : 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
@@ -3,6 +3,7 @@
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
// callers still own target-specific permission checks and request actions.
import RoverLabel from '../RoverLabel/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
function classNames(...values) {
return values.filter(Boolean).join(' ');
@@ -105,7 +106,7 @@ export default function QueueTargetRow({
return (
<li
className={classNames(
'surface flex flex-wrap items-start justify-between gap-0.5',
'surface relative flex flex-wrap items-start justify-between gap-0.5 overflow-hidden',
canClick && 'cursor-pointer',
locked
? 'bg-red-900/40'
@@ -191,6 +192,9 @@ export default function QueueTargetRow({
{buttonLabel}
</button>
) : null}
{/* Queue rows use the exact overlay component as the large video and
display surfaces; its measured font automatically adapts to this box. */}
<RoverHelpOverlay active={Boolean(target?.needsHelp)} />
</li>
);
}
@@ -0,0 +1,26 @@
// Rover Help Overlay
// Purpose: Gives every rover surface one unmistakable, responsive HELP treatment.
// Scope: Renders only the shared visual; server roster state decides when it is active.
import AutoFitText from '../AutoFitText/index.jsx';
import './styles.css';
export default function RoverHelpOverlay({ active = false }) {
if (!active) return null;
return (
<div
className="rover-help-overlay pointer-events-none absolute inset-0 z-50 overflow-hidden border-[clamp(0.2rem,1.2cqw,1rem)] border-white bg-red-600 p-[clamp(0.2rem,2cqw,1.5rem)] text-white"
role="status"
aria-label="Rover needs help"
>
<AutoFitText
fitHeight
minSize={8}
maxSize={1400}
className="font-black tracking-tight text-white"
>
HELP
</AutoFitText>
</div>
);
}
@@ -0,0 +1,9 @@
@keyframes rover-help-flash {
0%, 49% { opacity: 0.96; }
50%, 100% { opacity: 0; }
}
.rover-help-overlay {
container-type: size;
animation: rover-help-flash 2s steps(1, end) infinite;
}
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function SpectateVideo({
@@ -11,6 +12,7 @@ export default function SpectateVideo({
label,
fitParent = false,
layoutFormat = 'desktop',
needsHelp = false,
}) {
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
state.session?.role === 'spectator' &&
@@ -46,6 +48,7 @@ export default function SpectateVideo({
<OvercurrentOverlay roverId={roverId} compact={false} />
<LowBatteryOverlay roverId={roverId} compact={false} />
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
<RoverHelpOverlay active={needsHelp} />
</div>
</div>
);
@@ -5,7 +5,8 @@ import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.js
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import RoverLabel from '../../../components/RoverLabel/index.jsx';
import AutoFitText from '../../../mini/MiniSummaryApp/components/AutoFitText.jsx';
import AutoFitText from '../../../components/AutoFitText/index.jsx';
import RoverHelpOverlay from '../../../components/RoverHelpOverlay/index.jsx';
import {
buildRoverStateText,
findDriverForRover,
@@ -38,6 +39,7 @@ export default function DisplayRoverCell({ rover, session }) {
!locked && urgent ? 'ring-4 ring-red-500/90' : !locked && warn ? 'ring-2 ring-amber-300/80' : '',
)}
>
<RoverHelpOverlay active={Boolean(rover?.needsHelp)} />
<BatteryBar visual={visual} variant="background" orientation="vertical" />
<div className="relative z-10 grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-[0.55vh] p-[0.85vw] text-center">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
@@ -11,6 +11,7 @@ import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
import PtzLiveVideo from '../../components/PtzLiveVideo/index.jsx';
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
import RoverHelpOverlay from '../../components/RoverHelpOverlay/index.jsx';
import FitViewportFrame from './components/FitViewportFrame.jsx';
import InfoColumn from './components/InfoColumn.jsx';
import { ROTATE_MS } from './constants.js';
@@ -352,6 +353,7 @@ export default function MiniSummaryContent() {
/>
)}
</FitViewportFrame>
<RoverHelpOverlay active={Boolean(rover.needsHelp)} />
</div>
);
})}
@@ -1,65 +0,0 @@
// Auto Fit Text
// Purpose: Defines the Auto Fit Text module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useLayoutEffect, useRef, useState } from 'react';
export default function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14, style = undefined }) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let raf = null;
const fit = () => {
const width = container.clientWidth;
if (!width) {
scheduleFit();
return;
}
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
textEl.style.fontSize = `${mid}px`;
const fits = textEl.scrollWidth <= width;
if (fits) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(fit);
};
scheduleFit();
const ro = new ResizeObserver(scheduleFit);
ro.observe(container);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, [children, maxSize, minSize]);
return (
<div ref={containerRef} className="w-full min-w-0">
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
@@ -4,8 +4,9 @@
import RoverMediaPlayer from '../../../components/RoverMediaPlayer/index.jsx';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import RoverLabel from '../../../components/RoverLabel/index.jsx';
import RoverHelpOverlay from '../../../components/RoverHelpOverlay/index.jsx';
import AutoFitText from '../../../components/AutoFitText/index.jsx';
import { getBatteryVisual } from '../utils.js';
import AutoFitText from './AutoFitText.jsx';
export default function InfoColumn({
rover,
@@ -32,6 +33,10 @@ export default function InfoColumn({
orientation={isActiveView ? 'vertical' : 'horizontal'}
variant="background"
/>
{/* In the side-by-side roster this column is the rover's complete tile.
The active carousel already overlays its video pane, so suppressing a
second copy here avoids flashing HELP twice for the same rover. */}
<RoverHelpOverlay active={!isActiveView && Boolean(rover?.needsHelp)} />
{isActiveView ? (
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
@@ -10,6 +10,7 @@ export default function RoverSpectatorCard({ rover }) {
<SpectateVideo
roverId={rover.id}
label={rover.name}
needsHelp={Boolean(rover.needsHelp)}
/>
</div>
</article>