mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
buncha stuff, charging complete sounds, room display button feedback, etc
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
// audio Forward Service charge complete sound
|
||||
// Purpose: Plays the built-in rover sound effect when the battery manager marks a charging cycle done.
|
||||
// Scope: Keeps the battery policy and audio pipeline decoupled by listening to the server event bus only.
|
||||
const path = require('path');
|
||||
const { subscribe } = require('../eventBus');
|
||||
|
||||
const DONE_CHARGING_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'public', 'donecharging.wav');
|
||||
|
||||
function registerChargeCompleteSound(deps) {
|
||||
const {
|
||||
logger,
|
||||
playServerAudioFile,
|
||||
} = deps;
|
||||
|
||||
subscribe('battery.unlocked', (event = {}) => {
|
||||
const roverId = String(event?.payload?.roverId || '').trim();
|
||||
if (!roverId) return;
|
||||
|
||||
try {
|
||||
// battery.unlocked is the existing server-side definition of "done
|
||||
// charging" because it only fires after the battery manager has waited
|
||||
// through its full charge-release policy. Playing from this event avoids
|
||||
// duplicating charging thresholds or raw sensor-state guesses here.
|
||||
playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, {
|
||||
source: 'charge-complete',
|
||||
});
|
||||
logger.info('Played charging complete sound', { roverId, filePath: DONE_CHARGING_SOUND_PATH });
|
||||
} catch (err) {
|
||||
// A missing/offline rover or unavailable ffmpeg should not affect the
|
||||
// battery manager's unlock decision. The sound is an announcement layered
|
||||
// on top of the state transition, so failures are logged and contained.
|
||||
logger.warn('Failed to play charging complete sound', {
|
||||
roverId,
|
||||
filePath: DONE_CHARGING_SOUND_PATH,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerChargeCompleteSound,
|
||||
};
|
||||
@@ -13,6 +13,7 @@ const videoSessions = require('../videoSessions');
|
||||
const { createAudioForwardPolicy } = require('./policy');
|
||||
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
||||
const { registerAudioForwardHooks } = require('./hooks');
|
||||
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
|
||||
|
||||
const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
@@ -95,6 +96,7 @@ const {
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
playUploadedAudio,
|
||||
playServerAudioFile,
|
||||
stopPlayback,
|
||||
revokeWhipSessionForRover,
|
||||
stopWhipForRover,
|
||||
@@ -125,6 +127,11 @@ registerAudioForwardHooks({
|
||||
startSilenceWriter,
|
||||
});
|
||||
|
||||
registerChargeCompleteSound({
|
||||
logger,
|
||||
playServerAudioFile,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
|
||||
@@ -158,7 +158,7 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildUploadWriterArgs(filePath) {
|
||||
function buildFileWriterArgs(filePath) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
@@ -253,21 +253,41 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function startUploadWriter(roverId, filePath, ownerSocketId) {
|
||||
function startFileWriter(roverId, filePath, options = {}) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
const source = options.source || 'upload';
|
||||
const contentKind = options.contentKind || source;
|
||||
const ownerSocketId = options.ownerSocketId || null;
|
||||
const cleanupAfterPlayback = Boolean(options.cleanupAfterPlayback);
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeUploadPath = filePath;
|
||||
worker.activeOwnerSocketId = ownerSocketId || null;
|
||||
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||
|
||||
// Browser-uploaded files are temporary files that this service owns, so the
|
||||
// worker remembers them and deletes them when playback is interrupted or
|
||||
// replaced. Built-in server assets are deliberately not tracked here because
|
||||
// they are checked-in files shared across all rovers and must survive after
|
||||
// a single playback finishes.
|
||||
if (cleanupAfterPlayback) {
|
||||
worker.activeUploadPath = filePath;
|
||||
} else {
|
||||
worker.activeUploadPath = null;
|
||||
}
|
||||
|
||||
// Socket ownership is meaningful only for user-started upload playback.
|
||||
// Server-started sounds such as the charging-complete cue pass no owner so
|
||||
// turn changes and browser disconnects do not treat the built-in sound as a
|
||||
// stale client session.
|
||||
worker.activeOwnerSocketId = ownerSocketId;
|
||||
const proc = spawnFfmpeg(roverId, `${source}-writer`, buildFileWriterArgs(filePath), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'upload';
|
||||
worker.contentKind = contentKind;
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||
setState(roverId, { state: 'playing', source, error: null, startedAt: Date.now() });
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
@@ -279,8 +299,8 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'upload',
|
||||
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
|
||||
source,
|
||||
error: `${source} writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
@@ -414,7 +434,34 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
stopWhipForRover(roverId, 'upload_override');
|
||||
ensureWorker(roverId);
|
||||
const uploadPath = writeUploadFile(roverId, payload);
|
||||
startUploadWriter(roverId, uploadPath, ownerSocketId);
|
||||
startFileWriter(roverId, uploadPath, {
|
||||
source: 'upload',
|
||||
contentKind: 'upload',
|
||||
ownerSocketId,
|
||||
cleanupAfterPlayback: true,
|
||||
});
|
||||
}
|
||||
|
||||
function playServerAudioFile(roverId, filePath, options = {}) {
|
||||
const source = options.source || 'server-file';
|
||||
const normalizedPath = path.resolve(filePath || '');
|
||||
const stat = fs.statSync(normalizedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Audio file is not a regular file: ${normalizedPath}`);
|
||||
}
|
||||
|
||||
// Built-in sounds intentionally interrupt mic forwarding just like uploads
|
||||
// do. The rover can only publish one forwarded audio stream at a time, so
|
||||
// keeping WHIP alive would leave the automatic cue inaudible or mixed with
|
||||
// a stale publisher process.
|
||||
stopWhipForRover(roverId, `${source}_override`);
|
||||
ensureWorker(roverId);
|
||||
startFileWriter(roverId, normalizedPath, {
|
||||
source,
|
||||
contentKind: source,
|
||||
ownerSocketId: null,
|
||||
cleanupAfterPlayback: false,
|
||||
});
|
||||
}
|
||||
|
||||
function stopPlayback(roverId) {
|
||||
@@ -451,6 +498,7 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
playUploadedAudio,
|
||||
playServerAudioFile,
|
||||
stopPlayback,
|
||||
revokeWhipSessionForRover,
|
||||
stopWhipForRover,
|
||||
|
||||
@@ -6,6 +6,7 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeScannerService');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { sendAlert } = require('../alertService');
|
||||
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
|
||||
@@ -138,6 +139,18 @@ function broadcastState() {
|
||||
io.emit('barcode:state', buildStatePayload());
|
||||
}
|
||||
|
||||
function sendBarcodeScanAlert(result) {
|
||||
if (!result) return;
|
||||
// Barcode scan toasts use the existing alert feed so drivers/spectators get a
|
||||
// lightweight system notice without adding any scanner-specific panels. The
|
||||
// scanner page remains the source of the big local display and speech output.
|
||||
sendAlert({
|
||||
color: result.known ? '#22c55e' : '#f59e0b',
|
||||
title: 'Barcode Scanned',
|
||||
message: result.label || result.code || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
function resolveScan(rawCode) {
|
||||
const code = normalizeCode(rawCode);
|
||||
const loaded = loadRegistryForScan();
|
||||
@@ -179,8 +192,12 @@ function resolveScan(rawCode) {
|
||||
known: false,
|
||||
type: null,
|
||||
entityId: null,
|
||||
label: 'unknown',
|
||||
speechText: 'unknown',
|
||||
// Unknown but well-formed barcodes should be visible/audible as the code
|
||||
// itself. That makes mis-labeled objects and new unregistered barcodes
|
||||
// debuggable from the rover-facing scanner page without adding any extra
|
||||
// UI panels or registry-management logic to the browser.
|
||||
label: `Unknown: ${code}`,
|
||||
speechText: `Unknown: ${code}`,
|
||||
scannedAt,
|
||||
registryError: loaded.error || null,
|
||||
error: null,
|
||||
@@ -211,6 +228,7 @@ function applyScan(rawCode) {
|
||||
registryError: result.registryError || null,
|
||||
};
|
||||
broadcastState();
|
||||
sendBarcodeScanAlert(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the human Alert Button Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const sharp = require('sharp');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('humanAlertButton');
|
||||
const { subscribe, publishEvent } = require('../eventBus');
|
||||
const { getMode, MODES, setMode } = require('../modeManager');
|
||||
@@ -23,6 +24,7 @@ const LIGHTS_LOCKED_TTS = 'Room lights are now locked on.';
|
||||
const LIGHTS_UNLOCKED_TTS = 'Room lights are now unlocked.';
|
||||
const TILE_WIDTH = 480;
|
||||
const TILE_HEIGHT = 270;
|
||||
const DISPLAY_NOTICE_DURATION_MS = 4500;
|
||||
|
||||
logger.info('HA button actions enabled', {
|
||||
actions: [HUMAN_ALERT_ACTION, MODE_TURNS_ACTION, MODE_ADMIN_ACTION, LIGHTS_LOCK_TOGGLE_ACTION],
|
||||
@@ -51,6 +53,21 @@ function sendTtsToNonPrivateRovers(text) {
|
||||
});
|
||||
}
|
||||
|
||||
function emitDisplayNotice(text) {
|
||||
const clean = String(text || '').trim();
|
||||
if (!clean) return;
|
||||
// The room display should show the same successful action feedback that the
|
||||
// rovers speak. This socket event is intentionally display-specific so normal
|
||||
// driver/admin pages do not inherit a large visual interruption.
|
||||
io.emit('display:notice', {
|
||||
id: `ha-button-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
text: clean,
|
||||
source: 'ha-button',
|
||||
durationMs: DISPLAY_NOTICE_DURATION_MS,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function buildTile(camera, state) {
|
||||
const base = sharp({
|
||||
create: {
|
||||
@@ -118,6 +135,7 @@ async function handleTrigger(event = {}) {
|
||||
{ force: true },
|
||||
);
|
||||
sendTtsToNonPrivateRovers(MODE_TURNS_TTS);
|
||||
emitDisplayNotice(MODE_TURNS_TTS);
|
||||
return;
|
||||
}
|
||||
if (action === MODE_ADMIN_ACTION) {
|
||||
@@ -127,6 +145,7 @@ async function handleTrigger(event = {}) {
|
||||
{ force: true },
|
||||
);
|
||||
sendTtsToNonPrivateRovers(MODE_ADMIN_TTS);
|
||||
emitDisplayNotice(MODE_ADMIN_TTS);
|
||||
return;
|
||||
}
|
||||
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
|
||||
@@ -134,7 +153,9 @@ async function handleTrigger(event = {}) {
|
||||
source: 'ha-button:lightsLockToggle',
|
||||
forceApply: true,
|
||||
});
|
||||
sendTtsToNonPrivateRovers(lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS);
|
||||
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
|
||||
sendTtsToNonPrivateRovers(message);
|
||||
emitDisplayNotice(message);
|
||||
return;
|
||||
}
|
||||
if (action !== HUMAN_ALERT_ACTION) {
|
||||
@@ -161,6 +182,7 @@ async function handleTrigger(event = {}) {
|
||||
trigger: event?.payload || null,
|
||||
},
|
||||
});
|
||||
emitDisplayNotice(HUMAN_ALERT_MESSAGE);
|
||||
}
|
||||
|
||||
subscribe(HA_BUTTON_EVENT_TYPE, (event) => {
|
||||
|
||||
Reference in New Issue
Block a user