mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
improving the UI side of ptz... and improving the way turns works with UI....
This commit is contained in:
@@ -29,9 +29,10 @@ async function transition(socket, operatingMode) {
|
||||
// Block new rover ownership before release events notify other services.
|
||||
socket.data.operatingMode = 'ptz';
|
||||
assignment.releaseForOperatingMode(socket);
|
||||
ptz.claimTurn(socket);
|
||||
socket.data.ptzEntered = true;
|
||||
} else {
|
||||
ptz.releaseTurn(socket);
|
||||
socket.data.ptzEntered = false;
|
||||
socket.data.operatingMode = 'rover';
|
||||
assignment.resumeAssignment(socket);
|
||||
}
|
||||
@@ -58,8 +59,16 @@ function setOperatingMode(socket, operatingMode) {
|
||||
return enqueueTransition(socket, () => transition(socket, operatingMode));
|
||||
}
|
||||
|
||||
function releasePtzTurn(socket) {
|
||||
return enqueueTransition(socket, () => require('../ptzCameraService').releaseTurn(socket));
|
||||
function runPtzTurnAction(socket, action) {
|
||||
return enqueueTransition(socket, () => {
|
||||
const ptz = require('../ptzCameraService');
|
||||
if (action === 'release') return ptz.releaseTurn(socket);
|
||||
if (action !== 'request') throw new Error('Invalid PTZ turn action');
|
||||
if (getOperatingMode(socket) !== 'ptz' || !socket.data.ptzEntered) {
|
||||
throw new Error('Enter PTZ before requesting a turn');
|
||||
}
|
||||
return ptz.claimTurn(socket);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getOperatingMode, setOperatingMode, releasePtzTurn, operatingModeEvents };
|
||||
module.exports = { getOperatingMode, setOperatingMode, runPtzTurnAction, operatingModeEvents };
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
|
||||
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
@@ -78,6 +77,7 @@ function createPtzAudioPlayback(deps) {
|
||||
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
||||
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
||||
|
||||
let disposed = false;
|
||||
let playbackProc = null;
|
||||
let playbackSeq = 0;
|
||||
|
||||
@@ -244,12 +244,16 @@ function createPtzAudioPlayback(deps) {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
async function playFile(filePath, context = {}) {
|
||||
if (!audioEnabled) throw new Error('PTZ audio disabled');
|
||||
async function playFile(filePath, context = {}, authorize = () => {}) {
|
||||
if (disposed || !audioEnabled) throw new Error('PTZ audio unavailable');
|
||||
authorize();
|
||||
const neolinkConfigPath = await ensureNeolinkConfig();
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
|
||||
|
||||
// Rendering and config IO may outlive a turn or camera configuration.
|
||||
if (disposed) throw new Error('PTZ audio configuration changed');
|
||||
authorize();
|
||||
stopActivePlayback('new-playback');
|
||||
const seq = ++playbackSeq;
|
||||
const args = [
|
||||
@@ -302,13 +306,13 @@ function createPtzAudioPlayback(deps) {
|
||||
return { pid: proc.pid || null, seq };
|
||||
}
|
||||
|
||||
async function speakText(text, ttsOptions = {}, context = {}) {
|
||||
async function speakText(text, ttsOptions = {}, context = {}, authorize = () => {}) {
|
||||
const rendered = await ensureTtsFile(text, ttsOptions);
|
||||
await playFile(rendered.filePath, {
|
||||
...context,
|
||||
engine: rendered.engine,
|
||||
cached: rendered.cached,
|
||||
});
|
||||
}, authorize);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
@@ -316,6 +320,10 @@ function createPtzAudioPlayback(deps) {
|
||||
getState,
|
||||
speakText,
|
||||
stopActivePlayback,
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
stopActivePlayback('configuration-change');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
// Owns PTZ turns only. Camera IO, permissions and operating-mode entry are supplied
|
||||
// by the service, so queue transitions cannot create physical rover ownership.
|
||||
function createParticipation({ io, events, emitChange, getTurnDurationMs, canParticipate, stopMotion, revokeVideo }) {
|
||||
const state = { operatorSocketId: null, queue: [], deadline: null, generation: 0 };
|
||||
let timer = null;
|
||||
|
||||
function remove(socketId) {
|
||||
state.queue = state.queue.filter((id) => id !== socketId);
|
||||
}
|
||||
|
||||
function stopOperator(reason) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
const previous = state.operatorSocketId;
|
||||
state.operatorSocketId = null;
|
||||
state.deadline = null;
|
||||
state.generation += 1;
|
||||
if (!previous) return;
|
||||
revokeVideo(previous);
|
||||
stopMotion(reason).catch(() => {});
|
||||
events.emit('operator', { socketId: previous, action: 'release', reason });
|
||||
}
|
||||
|
||||
function scheduleTurn() {
|
||||
clearTimeout(timer);
|
||||
const duration = getTurnDurationMs();
|
||||
state.deadline = Date.now() + duration;
|
||||
timer = setTimeout(() => {
|
||||
// An expired operator stays when alone, but is not automatically requeued
|
||||
// when another participant takes over. Preserve the existing PTZ rule.
|
||||
state.queue = state.queue.filter((id) => eligible(id));
|
||||
if (state.queue.length) {
|
||||
stopOperator('turn-expired');
|
||||
advance('turn-expired');
|
||||
} else {
|
||||
scheduleTurn();
|
||||
emitChange('turn-extended-empty-queue');
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function eligible(socketId) {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
return socket && !socket.disconnected && canParticipate(socket);
|
||||
}
|
||||
|
||||
function advance(reason) {
|
||||
while (!state.operatorSocketId && state.queue.length) {
|
||||
const next = state.queue.shift();
|
||||
if (!eligible(next)) continue;
|
||||
state.operatorSocketId = next;
|
||||
state.generation += 1;
|
||||
scheduleTurn();
|
||||
events.emit('operator', { socketId: next, action: 'active' });
|
||||
}
|
||||
emitChange(reason);
|
||||
}
|
||||
|
||||
function claim(socket) {
|
||||
if (!eligible(socket.id)) throw new Error('PTZ participation required');
|
||||
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
|
||||
state.queue.push(socket.id);
|
||||
advance('queue-join');
|
||||
}
|
||||
|
||||
function release(socketId, reason = 'turn-release') {
|
||||
remove(socketId);
|
||||
if (state.operatorSocketId === socketId) stopOperator(reason);
|
||||
advance(reason);
|
||||
}
|
||||
|
||||
function reconcile() {
|
||||
state.queue = state.queue.filter(eligible);
|
||||
if (state.operatorSocketId && !eligible(state.operatorSocketId)) stopOperator('access-change');
|
||||
advance('access-change');
|
||||
}
|
||||
|
||||
function clear(reason) {
|
||||
state.queue = [];
|
||||
stopOperator(reason);
|
||||
}
|
||||
|
||||
return {
|
||||
state, claim, release, reconcile, clear,
|
||||
revoke: (reason) => { stopOperator(reason); advance(reason); },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createParticipation };
|
||||
@@ -0,0 +1,123 @@
|
||||
// Socket transport delegates policy and lifecycle decisions to the PTZ service.
|
||||
function registerSocketGateway(deps) {
|
||||
const { io, isAdmin, participation, emitChange, normalizeSocketArgs,
|
||||
runPtzTurnAction, acceptMotionIntent, getStatus, setSpotlight, setIr,
|
||||
listPresets, gotoPreset, createPreset, removePreset, passesMode,
|
||||
normalizeSnapshotIds, addSnapshotSubscription, sendSnapshotFrame,
|
||||
getSnapshot, removeSnapshotSubscriptions } = deps;
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
|
||||
participation.revoke('admin-revoke');
|
||||
cb({ ok: true });
|
||||
});
|
||||
socket.on('ptzCamera:requestTurn', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try { cb({ ok: true, state: await runPtzTurnAction(socket, 'request') }); }
|
||||
catch (err) { cb({ error: err.message }); }
|
||||
});
|
||||
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, state: await runPtzTurnAction(socket, 'release') });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
/*
|
||||
Acknowledge acceptance of the newest desired state immediately. The
|
||||
serialized ONVIF pump deliberately runs independently of Socket.IO
|
||||
request latency so browser heartbeats cannot accumulate while waiting
|
||||
for a camera SOAP response.
|
||||
*/
|
||||
cb(acceptMotionIntent(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:status', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, status: await getStatus(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:spotlight', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, light: await setSpotlight(socket, payload) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:ir', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, ir: await setIr(socket, payload) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, presets: await listPresets(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await gotoPreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await createPreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await removePreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
|
||||
const ids = normalizeSnapshotIds(payload);
|
||||
addSnapshotSubscription(socket, ids);
|
||||
const lastSnapshotState = getSnapshot();
|
||||
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
|
||||
cb({ ok: true, subscribed: ids });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
|
||||
const { payload } = normalizeSocketArgs(firstArg, secondArg);
|
||||
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
participation.release(socket.id, 'disconnect');
|
||||
removeSnapshotSubscriptions(socket.id);
|
||||
emitChange('disconnect');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerSocketGateway };
|
||||
@@ -0,0 +1,16 @@
|
||||
const labels = {
|
||||
waiting: 'Someone else has control',
|
||||
handoff: 'It’s your turn!',
|
||||
active: 'You control the camera',
|
||||
idleWarning: 'Start driving or your turn will be skipped',
|
||||
activeTimer: 'Your turn',
|
||||
waitingTimer: 'Waiting',
|
||||
untilTurn: 'until your turn',
|
||||
timeRemaining: 'left',
|
||||
showTimer: 'Show turn timer',
|
||||
hideTimer: 'Hide turn timer',
|
||||
showName: 'Show camera name',
|
||||
hideName: 'Hide camera name',
|
||||
};
|
||||
|
||||
module.exports = labels;
|
||||
@@ -11,6 +11,7 @@ const roverManager = require('../roverManager');
|
||||
const { managerEvents } = roverManager;
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
|
||||
const { buildRoverTurn } = require('../turnService/display');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const {
|
||||
getPublicState: getPtzCameraState,
|
||||
@@ -194,7 +195,16 @@ function buildSession(socket) {
|
||||
of navigation and layout without maintaining another feature registry.
|
||||
*/
|
||||
features,
|
||||
roster,
|
||||
roster: roster.map((rover) => ({
|
||||
...rover,
|
||||
turn: buildRoverTurn({
|
||||
rover,
|
||||
mode: getMode(),
|
||||
socketId: socket?.id,
|
||||
turnInfo: turnQueues[rover.id],
|
||||
activeDriverId: activeDrivers[rover.id],
|
||||
}),
|
||||
})),
|
||||
odometers: roverManager.getOdometersForSocket(socket),
|
||||
assignment: {
|
||||
...assignment,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
|
||||
|
||||
const labels = {
|
||||
waiting: 'Someone else is driving',
|
||||
handoff: 'It’s your turn!',
|
||||
active: 'You’re driving',
|
||||
idleWarning: 'Start driving or your turn will be skipped',
|
||||
activeTimer: 'Your turn',
|
||||
waitingTimer: 'Waiting',
|
||||
untilTurn: 'until your turn',
|
||||
timeRemaining: 'left',
|
||||
showTimer: 'Show turn timer',
|
||||
hideTimer: 'Hide turn timer',
|
||||
showName: 'Show rover name',
|
||||
hideName: 'Hide rover name',
|
||||
};
|
||||
|
||||
// Build this from visibility-filtered session data so private rover state stays private.
|
||||
function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId }) {
|
||||
const queue = turnInfo?.queue || [];
|
||||
// Direct ownership can arrive before queue details on load/reconnect.
|
||||
const currentDriverId = activeDriverId || turnInfo?.current || null;
|
||||
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
|
||||
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
||||
const enabled = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
||||
const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
||||
return {
|
||||
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
|
||||
enabled,
|
||||
isActive: turnsAhead === 0,
|
||||
turnsAhead,
|
||||
queueLength: queue.length,
|
||||
deadline: turnInfo?.deadline || null,
|
||||
durationMs: TURN_DURATION_MS,
|
||||
idleDeadline: turnInfo?.idleDeadline || null,
|
||||
idleGraceMs: IDLE_TIMEOUT_MS,
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildRoverTurn };
|
||||
Reference in New Issue
Block a user