Files
MultiRoombaRover/server/src/services/roverHelpService/hornNotifier.js
T

144 lines
4.3 KiB
JavaScript

// 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;
const HELP_ROOMBA_NOTE = 95;
const HELP_ROOMBA_NOTE_DURATION = 16;
const HELP_ROOMBA_SONG_SLOT = 4;
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);
// 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
// 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) return false;
let sounded = false;
let externalHornStarted = false;
if (record.meta?.horn?.enabled) {
try {
issueCommand(id, {
type: 'horn',
horn: {
action: 'start',
waveform: 'saw',
freqs: [HELP_HORN_FREQUENCY_HZ],
},
});
sounded = true;
externalHornStarted = true;
} catch (err) {
logger?.warn?.('Failed to start rover help horn', { roverId: id, error: err.message });
}
}
try {
// Roomba 600-series songs use MIDI notes and 1/64-second durations.
// Note 95 is approximately 1975.5 Hz, the closest supported pitch to the
// external 2000 Hz horn, and duration 16 matches its 250 ms pulse.
issueCommand(id, {
type: 'song',
song: {
slot: HELP_ROOMBA_SONG_SLOT,
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
});
sounded = true;
} catch (err) {
logger?.warn?.('Failed to play rover help song', { roverId: id, error: err.message });
}
if (externalHornStarted) {
// There can be only one pending automatic stop for a rover. Replacing an
// 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) {
const id = String(roverId);
if (intervals.has(id)) return;
const record = getRover?.(id);
if (!record?.ws) 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,
HELP_ROOMBA_NOTE,
HELP_ROOMBA_NOTE_DURATION,
HELP_ROOMBA_SONG_SLOT,
createHelpHornNotifier,
};