mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
buncha stuff, charging complete sounds, room display button feedback, etc
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-D3PfbwGG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BDdrX06B.css">
|
||||
<script type="module" crossorigin src="/assets/index-BJBZKaDf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BIlplCN6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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