mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
horn beep help thing yay
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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 || !record?.meta?.horn?.enabled) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
issueCommand(id, {
|
||||||
|
type: 'horn',
|
||||||
|
horn: {
|
||||||
|
action: 'start',
|
||||||
|
waveform: 'saw',
|
||||||
|
freqs: [HELP_HORN_FREQUENCY_HZ],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.('Failed to start rover help horn', { roverId: id, error: err.message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// There can be only one pending automatic stop for a rover. Replacing an
|
||||||
|
// unexpected stale timer keeps the pulse duration bounded even if chirp is
|
||||||
|
// called manually in addition to its normal five-second interval.
|
||||||
|
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 true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(roverId) {
|
||||||
|
const id = String(roverId);
|
||||||
|
if (intervals.has(id)) return;
|
||||||
|
const record = getRover?.(id);
|
||||||
|
if (!record?.ws || !record?.meta?.horn?.enabled) 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,
|
||||||
|
createHelpHornNotifier,
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// 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,
|
||||||
|
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 an immediate 2000 Hz saw chirp 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] },
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
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('does not schedule chirps for a rover without an enabled horn', () => {
|
||||||
|
const harness = createHarness({ enabled: false });
|
||||||
|
harness.notifier.start('green');
|
||||||
|
assert.equal(harness.commands.length, 0);
|
||||||
|
assert.equal(harness.intervals.size, 0);
|
||||||
|
assert.equal(harness.timeouts.size, 0);
|
||||||
|
});
|
||||||
@@ -5,9 +5,19 @@ const roverManager = require('../roverManager');
|
|||||||
const { sendAlert } = require('../alertService');
|
const { sendAlert } = require('../alertService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
||||||
|
const { createHelpHornNotifier } = require('./hornNotifier');
|
||||||
|
|
||||||
const HELP_ALERT_COLOR = '#ef4444';
|
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({
|
const monitor = createRoverHelpMonitor({
|
||||||
onChange({ roverId, needsHelp, addedReason, reasons }) {
|
onChange({ roverId, needsHelp, addedReason, reasons }) {
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
@@ -16,6 +26,7 @@ const monitor = createRoverHelpMonitor({
|
|||||||
roverManager.setNeedsHelp(roverId, needsHelp);
|
roverManager.setNeedsHelp(roverId, needsHelp);
|
||||||
|
|
||||||
if (!wasNeedingHelp && needsHelp) {
|
if (!wasNeedingHelp && needsHelp) {
|
||||||
|
hornNotifier.start(roverId);
|
||||||
const reason = REASON_LABELS[addedReason] || 'a sustained rover fault was detected';
|
const reason = REASON_LABELS[addedReason] || 'a sustained rover fault was detected';
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: HELP_ALERT_COLOR,
|
color: HELP_ALERT_COLOR,
|
||||||
@@ -30,6 +41,7 @@ const monitor = createRoverHelpMonitor({
|
|||||||
payload: { roverId, roverName: record.meta?.name || roverId, reason, reasons },
|
payload: { roverId, roverName: record.meta?.name || roverId, reason, reasons },
|
||||||
});
|
});
|
||||||
} else if (wasNeedingHelp && !needsHelp) {
|
} else if (wasNeedingHelp && !needsHelp) {
|
||||||
|
hornNotifier.stop(roverId);
|
||||||
publishEvent({
|
publishEvent({
|
||||||
source: 'roverHelpService',
|
source: 'roverHelpService',
|
||||||
type: 'rover.helpCleared',
|
type: 'rover.helpCleared',
|
||||||
@@ -50,7 +62,10 @@ roverManager.managerEvents.on('dockGuard', (event) => {
|
|||||||
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
||||||
// A reconnect gets a fresh record and fresh persistence timers; stale sensor
|
// A reconnect gets a fresh record and fresh persistence timers; stale sensor
|
||||||
// history from a disconnected chassis must never immediately restore HELP.
|
// history from a disconnected chassis must never immediately restore HELP.
|
||||||
if (action === 'removed') monitor.removeRover(roverId);
|
if (action === 'removed') {
|
||||||
|
hornNotifier.stop(roverId);
|
||||||
|
monitor.removeRover(roverId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { monitor };
|
module.exports = { hornNotifier, monitor };
|
||||||
|
|||||||
Reference in New Issue
Block a user