From 3b7b2ac21e288d40bd125464c74a19c01b1fee50 Mon Sep 17 00:00:00 2001 From: legop3 Date: Sat, 12 Sep 2026 17:17:18 -0400 Subject: [PATCH] horn beep help thing yay --- .../services/roverHelpService/hornNotifier.js | 110 ++++++++++++++++++ .../roverHelpService/hornNotifier.test.js | 74 ++++++++++++ server/src/services/roverHelpService/index.js | 19 ++- 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 server/src/services/roverHelpService/hornNotifier.js create mode 100644 server/src/services/roverHelpService/hornNotifier.test.js diff --git a/server/src/services/roverHelpService/hornNotifier.js b/server/src/services/roverHelpService/hornNotifier.js new file mode 100644 index 00000000..dd5e1ee0 --- /dev/null +++ b/server/src/services/roverHelpService/hornNotifier.js @@ -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, +}; diff --git a/server/src/services/roverHelpService/hornNotifier.test.js b/server/src/services/roverHelpService/hornNotifier.test.js new file mode 100644 index 00000000..45405efb --- /dev/null +++ b/server/src/services/roverHelpService/hornNotifier.test.js @@ -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); +}); diff --git a/server/src/services/roverHelpService/index.js b/server/src/services/roverHelpService/index.js index c93b1b7c..cdbdd342 100644 --- a/server/src/services/roverHelpService/index.js +++ b/server/src/services/roverHelpService/index.js @@ -5,9 +5,19 @@ 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)); @@ -16,6 +26,7 @@ const monitor = createRoverHelpMonitor({ 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, @@ -30,6 +41,7 @@ const monitor = createRoverHelpMonitor({ payload: { roverId, roverName: record.meta?.name || roverId, reason, reasons }, }); } else if (wasNeedingHelp && !needsHelp) { + hornNotifier.stop(roverId); publishEvent({ source: 'roverHelpService', type: 'rover.helpCleared', @@ -50,7 +62,10 @@ roverManager.managerEvents.on('dockGuard', (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); + if (action === 'removed') { + hornNotifier.stop(roverId); + monitor.removeRover(roverId); + } }); -module.exports = { monitor }; +module.exports = { hornNotifier, monitor };