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
@@ -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']);
});