This commit is contained in:
legop3
2026-09-12 16:16:59 -04:00
parent 99d2a7689f
commit fb31ff52bd
28 changed files with 545 additions and 88 deletions
+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,56 @@
// 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 HELP_ALERT_COLOR = '#ef4444';
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) {
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) {
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') monitor.removeRover(roverId);
});
module.exports = { monitor };
@@ -0,0 +1,159 @@
// 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 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 docked = Boolean(sensors?.chargingSources?.homeBase);
const oiMode = sensors?.oiMode?.label || null;
if (oiMode === 'passive') state.dockGuardSawPassive = true;
if (docked || (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,99 @@
// 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']);
});
@@ -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();