improving the UI side of ptz... and improving the way turns works with UI....

This commit is contained in:
legop3
2026-09-24 17:54:46 -04:00
parent 846c9dae55
commit ffd34de56c
57 changed files with 2747 additions and 3179 deletions
@@ -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 -1
View File
@@ -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 };