also beep roomba at the same time

This commit is contained in:
legop3
2026-09-12 17:44:12 -04:00
parent 3b7b2ac21e
commit 3ec45de4b4
2 changed files with 100 additions and 43 deletions
@@ -4,6 +4,9 @@
const HELP_HORN_FREQUENCY_HZ = 2000; const HELP_HORN_FREQUENCY_HZ = 2000;
const HELP_HORN_DURATION_MS = 250; const HELP_HORN_DURATION_MS = 250;
const HELP_HORN_INTERVAL_MS = 5 * 1000; const HELP_HORN_INTERVAL_MS = 5 * 1000;
const HELP_ROOMBA_NOTE = 95;
const HELP_ROOMBA_NOTE_DURATION = 16;
const HELP_ROOMBA_SONG_SLOT = 4;
function createHelpHornNotifier({ function createHelpHornNotifier({
getRover, getRover,
@@ -23,6 +26,10 @@ function createHelpHornNotifier({
if (stopTimer != null) clearTimeoutFn(stopTimer); if (stopTimer != null) clearTimeoutFn(stopTimer);
stopTimers.delete(id); 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 // 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 // the middle of a 250 ms chirp silences it immediately instead of waiting
// for a timeout that was just cancelled. // for a timeout that was just cancelled.
@@ -38,48 +45,71 @@ function createHelpHornNotifier({
function chirp(roverId) { function chirp(roverId) {
const id = String(roverId); const id = String(roverId);
const record = getRover?.(id); const record = getRover?.(id);
if (!record?.ws || !record?.meta?.horn?.enabled) return false; if (!record?.ws) return false;
try { let sounded = false;
issueCommand(id, { let externalHornStarted = false;
type: 'horn', if (record.meta?.horn?.enabled) {
horn: { try {
action: 'start', issueCommand(id, {
waveform: 'saw', type: 'horn',
freqs: [HELP_HORN_FREQUENCY_HZ], horn: {
}, action: 'start',
}); waveform: 'saw',
} catch (err) { freqs: [HELP_HORN_FREQUENCY_HZ],
logger?.warn?.('Failed to start rover help horn', { roverId: id, error: err.message }); },
return false; });
sounded = true;
externalHornStarted = true;
} catch (err) {
logger?.warn?.('Failed to start rover help horn', { roverId: id, error: err.message });
}
} }
// There can be only one pending automatic stop for a rover. Replacing an try {
// unexpected stale timer keeps the pulse duration bounded even if chirp is // Roomba 600-series songs use MIDI notes and 1/64-second durations.
// called manually in addition to its normal five-second interval. // Note 95 is approximately 1975.5 Hz, the closest supported pitch to the
const previousStop = stopTimers.get(id); // external 2000 Hz horn, and duration 16 matches its 250 ms pulse.
if (previousStop != null) clearTimeoutFn(previousStop); issueCommand(id, {
stopTimers.set( type: 'song',
id, song: {
setTimeoutFn(() => { slot: HELP_ROOMBA_SONG_SLOT,
stopTimers.delete(id); notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
const current = getRover?.(id); },
if (!current?.ws) return; });
try { sounded = true;
issueCommand(id, { type: 'horn', horn: { action: 'stop' } }); } catch (err) {
} catch (err) { logger?.warn?.('Failed to play rover help song', { roverId: id, error: err.message });
logger?.warn?.('Failed to finish rover help chirp', { roverId: id, error: err.message }); }
}
}, HELP_HORN_DURATION_MS), if (externalHornStarted) {
); // There can be only one pending automatic stop for a rover. Replacing an
return true; // 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) { function start(roverId) {
const id = String(roverId); const id = String(roverId);
if (intervals.has(id)) return; if (intervals.has(id)) return;
const record = getRover?.(id); const record = getRover?.(id);
if (!record?.ws || !record?.meta?.horn?.enabled) return; if (!record?.ws) return;
// Sound immediately so a newly detected rover can be located without // Sound immediately so a newly detected rover can be located without
// waiting through the first five-second interval. // waiting through the first five-second interval.
@@ -106,5 +136,8 @@ module.exports = {
HELP_HORN_DURATION_MS, HELP_HORN_DURATION_MS,
HELP_HORN_FREQUENCY_HZ, HELP_HORN_FREQUENCY_HZ,
HELP_HORN_INTERVAL_MS, HELP_HORN_INTERVAL_MS,
HELP_ROOMBA_NOTE,
HELP_ROOMBA_NOTE_DURATION,
HELP_ROOMBA_SONG_SLOT,
createHelpHornNotifier, createHelpHornNotifier,
}; };
@@ -6,6 +6,9 @@ const {
HELP_HORN_DURATION_MS, HELP_HORN_DURATION_MS,
HELP_HORN_FREQUENCY_HZ, HELP_HORN_FREQUENCY_HZ,
HELP_HORN_INTERVAL_MS, HELP_HORN_INTERVAL_MS,
HELP_ROOMBA_NOTE,
HELP_ROOMBA_NOTE_DURATION,
HELP_ROOMBA_SONG_SLOT,
createHelpHornNotifier, createHelpHornNotifier,
} = require('./hornNotifier'); } = require('./hornNotifier');
@@ -35,17 +38,29 @@ function createHarness({ enabled = true } = {}) {
return { clearedIntervals, clearedTimeouts, commands, intervals, notifier, timeouts }; return { clearedIntervals, clearedTimeouts, commands, intervals, notifier, timeouts };
} }
test('starts an immediate 2000 Hz saw chirp and schedules the agreed cadence', () => { test('starts matching external and Roomba chirps and schedules the agreed cadence', () => {
const harness = createHarness(); const harness = createHarness();
harness.notifier.start('red'); harness.notifier.start('red');
assert.deepEqual(harness.commands, [{ assert.deepEqual(harness.commands, [
roverId: 'red', {
payload: { roverId: 'red',
type: 'horn', payload: {
horn: { action: 'start', waveform: 'saw', freqs: [HELP_HORN_FREQUENCY_HZ] }, type: 'horn',
horn: { action: 'start', waveform: 'saw', freqs: [HELP_HORN_FREQUENCY_HZ] },
},
}, },
}]); {
roverId: 'red',
payload: {
type: 'song',
song: {
slot: HELP_ROOMBA_SONG_SLOT,
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.intervals.values())[0].ms, HELP_HORN_INTERVAL_MS);
assert.equal(Array.from(harness.timeouts.values())[0].ms, HELP_HORN_DURATION_MS); assert.equal(Array.from(harness.timeouts.values())[0].ms, HELP_HORN_DURATION_MS);
@@ -65,10 +80,19 @@ test('stop cancels cadence and pending pulse before issuing a final horn stop',
assert.equal(harness.commands.at(-1).payload.horn.action, 'stop'); assert.equal(harness.commands.at(-1).payload.horn.action, 'stop');
}); });
test('does not schedule chirps for a rover without an enabled horn', () => { test('still schedules the Roomba song when the external horn is disabled', () => {
const harness = createHarness({ enabled: false }); const harness = createHarness({ enabled: false });
harness.notifier.start('green'); harness.notifier.start('green');
assert.equal(harness.commands.length, 0); assert.deepEqual(harness.commands, [{
assert.equal(harness.intervals.size, 0); roverId: 'green',
payload: {
type: 'song',
song: {
slot: HELP_ROOMBA_SONG_SLOT,
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
},
}]);
assert.equal(harness.intervals.size, 1);
assert.equal(harness.timeouts.size, 0); assert.equal(harness.timeouts.size, 0);
}); });