mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
487 lines
19 KiB
JavaScript
487 lines
19 KiB
JavaScript
// PTZ policy and session facade. Camera IO and participation each own their lifecycle.
|
|
const { getNickname: getNicknameForQueue } = require('../nicknameService');
|
|
const { describeQueue } = require('../turnService/queueDisplay');
|
|
const EventEmitter = require('events');
|
|
|
|
const io = require('../../globals/io');
|
|
const logger = require('../../globals/logger').child('ptzCamera');
|
|
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
|
|
const {
|
|
shouldUseSnapshotsForNonTurnVideo,
|
|
shouldUseSnapshotsForExternalSpectatorVideo,
|
|
} = require('../../helpers/bandwidthSavings');
|
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
|
const { isAdmin, isLockdownAdmin, getRole, roleEvents } = require('../roleService');
|
|
const { isVerified, verificationEvents } = require('../verificationService');
|
|
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
|
const roverManager = require('../roverManager');
|
|
const { getOperatingMode, runPtzTurnAction } = require('../operatingModeService');
|
|
const videoSessions = require('../videoSessions');
|
|
|
|
const { createCameraRuntime } = require('./cameraRuntime');
|
|
const { createParticipation } = require('./participation');
|
|
const { registerSocketGateway } = require('./socketGateway');
|
|
const turnLabels = require('./turnLabels');
|
|
const PTZ_CAMERA_ID = 'ptz-camera';
|
|
const PTZ_STREAM_PATH = 'ptz-camera';
|
|
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
|
|
const DEFAULT_REPLAY_ENABLED = true;
|
|
const DEFAULT_PTZ_COLOR = '#387bf8';
|
|
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
|
|
const events = new EventEmitter();
|
|
let cameraConfig = loadConfig().ptzCamera || {};
|
|
let enabled = Boolean(cameraConfig.enabled);
|
|
const snapshotSubscribers = new Map();
|
|
const socketSnapshotSubscriptions = new Map();
|
|
const snapshotLastSentBySocket = new Map();
|
|
let runtime = createCameraRuntime({ cameraConfig, logger, events, onChange: emitChange, getSocketLabel });
|
|
const participation = createParticipation({
|
|
io, events, emitChange, getTurnDurationMs,
|
|
canParticipate: (socket) => getOperatingMode(socket) === 'ptz' && Boolean(socket.data?.ptzEntered) && canUsePtzFeature(socket),
|
|
stopMotion: (reason) => runtime.forceMotionStop(reason),
|
|
revokeVideo: (socketId) => videoSessions.revokeWhere((info) => info.socketId === socketId && info.sourceType === 'ptz'),
|
|
});
|
|
|
|
function emitChange(reason = 'change') {
|
|
events.emit('change', { reason, state: getPublicState() });
|
|
}
|
|
|
|
function getTurnDurationMs() {
|
|
const configured = Number(cameraConfig.turnDurationMs);
|
|
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_TURN_DURATION_MS;
|
|
}
|
|
|
|
function isReplayEnabled() {
|
|
/*
|
|
Keep this as a single helper so the replay source catalog and the replay
|
|
worker catalog cannot drift apart. If PTZ replay is off, the UI should not
|
|
advertise a source that no worker is recording.
|
|
*/
|
|
return cameraConfig.replayEnabled === undefined ? DEFAULT_REPLAY_ENABLED : Boolean(cameraConfig.replayEnabled);
|
|
}
|
|
|
|
function passesMode(socket) {
|
|
const mode = getMode();
|
|
if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket);
|
|
if (mode === MODES.ADMIN) {
|
|
const role = getRole(socket);
|
|
return role === 'spectator' || isAdmin(socket);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function canUsePtzFeature(socket) {
|
|
if (!enabled || !socket) return false;
|
|
if (!passesMode(socket)) return false;
|
|
/*
|
|
The camera is a VIP feature during normal operation. Admins are allowed so
|
|
maintenance and testing do not depend on the verification database state,
|
|
while lockdown mode is already narrowed to lockdown admins by passesMode().
|
|
*/
|
|
return Boolean(isVerified(socket) || isAdmin(socket) || isLockdownAdmin(socket));
|
|
}
|
|
|
|
function getSocketLabel(socketId) {
|
|
const socket = io.sockets.sockets.get(socketId);
|
|
return socket?.data?.nickname || socket?.data?.user?.username || socketId || null;
|
|
}
|
|
|
|
function accessDenial(socket) {
|
|
if (!enabled) return 'PTZ camera is disabled';
|
|
if (!socket || !passesMode(socket)) return 'PTZ is unavailable in the current server mode';
|
|
if (!canUsePtzFeature(socket)) return 'PTZ requires verification or admin access';
|
|
return null;
|
|
}
|
|
|
|
function describeParticipation(socket) {
|
|
const denialReason = accessDenial(socket);
|
|
const entered = getOperatingMode(socket) === 'ptz' && Boolean(socket?.data?.ptzEntered);
|
|
const isOperator = Boolean(socket && participation.state.operatorSocketId === socket.id);
|
|
const position = socket ? participation.state.queue.indexOf(socket.id) + 1 : 0;
|
|
const currentId = participation.state.operatorSocketId;
|
|
const queue = currentId ? [currentId, ...participation.state.queue] : [...participation.state.queue];
|
|
const ready = enabled && runtime.state.initialized;
|
|
const canControl = !denialReason && entered && isOperator && ready;
|
|
return {
|
|
participation: {
|
|
status: denialReason ? 'unavailable' : !entered ? 'outside' : isOperator ? 'operating' : position ? 'queued' : 'idle',
|
|
entered,
|
|
denialReason,
|
|
},
|
|
permissions: {
|
|
canEnter: !denialReason,
|
|
canRequestTurn: !denialReason && entered && ready && !isOperator && !position,
|
|
canReleaseTurn: isOperator || position > 0,
|
|
canControl,
|
|
canListPresets: !denialReason && ready,
|
|
canCreatePreset: !denialReason && ready,
|
|
canRemovePreset: !denialReason && ready && isAdmin(socket),
|
|
},
|
|
viewMode: socket && canRequestLiveVideo(socket) ? 'live' : 'snapshot',
|
|
turn: {
|
|
...describeQueue(queue, currentId, queue.map((id) => {
|
|
const participant = io.sockets.sockets.get(id);
|
|
return { socketId: id, nickname: getNicknameForQueue(participant), role: getRole(participant) };
|
|
})),
|
|
target: {
|
|
id: PTZ_CAMERA_ID,
|
|
name: cameraConfig.name || 'PTZ Camera',
|
|
color: cameraConfig.color || DEFAULT_PTZ_COLOR,
|
|
fallback: null,
|
|
},
|
|
enabled: entered && (isOperator || position > 0) && participation.state.queue.length > 0,
|
|
ownsControl: isOperator,
|
|
turnsAhead: isOperator ? 0 : position || null,
|
|
queueLength: participation.state.queue.length + (participation.state.operatorSocketId ? 1 : 0),
|
|
deadline: participation.state.deadline,
|
|
durationMs: getTurnDurationMs(),
|
|
idleDeadline: null,
|
|
idleGraceMs: 7000,
|
|
labels: turnLabels,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getPublicState(socket = null) {
|
|
return {
|
|
enabled,
|
|
id: PTZ_CAMERA_ID,
|
|
name: cameraConfig.name || 'PTZ Camera',
|
|
color: cameraConfig.color || DEFAULT_PTZ_COLOR,
|
|
initialized: runtime.state.initialized,
|
|
initializing: runtime.state.initializing,
|
|
error: runtime.state.error,
|
|
streamPath: runtime.state.streamPath,
|
|
status: runtime.state.status,
|
|
light: runtime.state.light,
|
|
ir: runtime.state.ir,
|
|
presets: runtime.state.presets,
|
|
presetsError: runtime.state.presetsError,
|
|
publisher: runtime.state.publisher,
|
|
reolinkApi: runtime.state.reolinkApi,
|
|
audio: runtime.getAudioState(),
|
|
...describeParticipation(socket),
|
|
};
|
|
}
|
|
|
|
function getOperatingModeDisplay(socketId) {
|
|
const socket = io.sockets.sockets.get(socketId);
|
|
if (!socket || !socket.data?.ptzEntered || getOperatingMode(socket) !== 'ptz') return null;
|
|
return {
|
|
name: cameraConfig.name || 'PTZ Camera',
|
|
color: cameraConfig.color || DEFAULT_PTZ_COLOR,
|
|
};
|
|
}
|
|
|
|
function getParticipantSocketIds() {
|
|
// A released/expired camera turn does not end participation in PTZ mode.
|
|
return Array.from(io.sockets.sockets.values())
|
|
.filter((socket) => getOperatingMode(socket) === 'ptz' && Boolean(socket.data?.ptzEntered) && canUsePtzFeature(socket))
|
|
.map((socket) => socket.id);
|
|
}
|
|
|
|
function countControllableUsers() {
|
|
const ids = new Set();
|
|
io.sockets.sockets.forEach((candidate) => {
|
|
if (!candidate?.id || getRole(candidate) === 'spectator') return;
|
|
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
|
|
ids.add(candidate.id);
|
|
}
|
|
});
|
|
getParticipantSocketIds().forEach((socketId) => {
|
|
const socket = io.sockets.sockets.get(socketId);
|
|
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
|
|
});
|
|
return ids.size;
|
|
}
|
|
|
|
function canSpeakThroughPtz(socket) {
|
|
/*
|
|
PTZ chat carries its own operating-mode identity and camera queue rather
|
|
than a roverManager driver record. Match the rover TTS rule closely: the
|
|
current operator may speak, and queued users may prepare/use TTS while they
|
|
are in the camera queue. canUsePtzFeature keeps the normal VIP/admin/mode
|
|
access gates in front of both cases.
|
|
*/
|
|
if (getOperatingMode(socket) !== 'ptz' || !canUsePtzFeature(socket)) return false;
|
|
const socketId = socket?.id ? String(socket.id) : '';
|
|
if (!socketId) return false;
|
|
return participation.state.operatorSocketId === socketId || participation.state.queue.includes(socketId);
|
|
}
|
|
|
|
async function speakText(text, ttsOptions = {}, socket = null) {
|
|
if (!canSpeakThroughPtz(socket)) {
|
|
throw new Error('Only the PTZ operator or queue can use PTZ TTS');
|
|
}
|
|
return runtime.speakText(text, ttsOptions, { socketId: socket?.id || null }, () => {
|
|
if (!canSpeakThroughPtz(socket)) throw new Error('PTZ speech access changed');
|
|
});
|
|
}
|
|
|
|
function checkParticipationAccess(socket) {
|
|
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
|
if (!runtime.state.initialized) throw new Error(runtime.state.error || 'PTZ camera is not ready');
|
|
}
|
|
|
|
async function prepareParticipation(socket) {
|
|
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
|
await runtime.initialize();
|
|
}
|
|
|
|
function requireOperator(socket) {
|
|
if (!enabled) throw new Error('PTZ camera disabled');
|
|
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
|
if (!socket || !socket.data?.ptzEntered || getOperatingMode(socket) !== 'ptz' || participation.state.operatorSocketId !== socket.id) throw new Error('Not the PTZ operator');
|
|
}
|
|
|
|
function requirePtzUser(socket) {
|
|
/*
|
|
Listing presets does not move the camera, but it still reveals operational
|
|
camera state. Check the camera's own enabled switch just like queue entry so
|
|
unverified users cannot query PTZ-only data through raw socket calls.
|
|
*/
|
|
if (!enabled) throw new Error('PTZ camera disabled');
|
|
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
|
}
|
|
|
|
function requirePresetAdmin(socket) {
|
|
/*
|
|
Preset removal is intentionally narrower than normal PTZ operation because
|
|
deleting a shared camera position is destructive for every future operator.
|
|
Creation now uses requirePtzUser instead so any authorized PTZ user can save
|
|
a useful current position without also being allowed to remove presets.
|
|
*/
|
|
if (!enabled) throw new Error('PTZ camera disabled');
|
|
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
|
if (!isAdmin(socket) && !isLockdownAdmin(socket)) throw new Error('PTZ preset admin required');
|
|
}
|
|
|
|
function canRequestLiveVideo(socket) {
|
|
if (!enabled || !passesMode(socket)) return false;
|
|
if (participation.state.operatorSocketId === socket?.id) return canUsePtzFeature(socket);
|
|
if (isAdmin(socket) || isLockdownAdmin(socket)) return true;
|
|
const role = getRole(socket);
|
|
const local = isLocalNetwork(getSocketIp(socket));
|
|
if (role === 'spectator') {
|
|
/*
|
|
Spectator PTZ viewing follows the spectator bandwidth switch. LAN viewers
|
|
stay live because they do not consume server upload; non-local spectators
|
|
only get live PTZ when the external spectator video policy allows it.
|
|
*/
|
|
return local || !shouldUseSnapshotsForExternalSpectatorVideo();
|
|
}
|
|
if (
|
|
canUsePtzFeature(socket) &&
|
|
!shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() })
|
|
) {
|
|
/*
|
|
Verified/VIP users who can queue or claim the camera are PTZ "turn"
|
|
participants even before they become operator. When non-turn video is set
|
|
to live, they may watch the live feed while waiting; camera movement still
|
|
remains limited to the active operator by the command handlers.
|
|
*/
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeSnapshotIds(payload = {}) {
|
|
/*
|
|
PTZ only has one camera today, but accepting the same { ids } payload shape
|
|
as rover snapshots keeps the browser subscription lifecycle consistent.
|
|
Unknown ids are ignored rather than treated as separate PTZ cameras.
|
|
*/
|
|
const rawIds = Array.isArray(payload.ids) ? payload.ids : [payload.id || PTZ_CAMERA_ID];
|
|
const ids = rawIds.map((id) => String(id || '').trim()).filter((id) => id === PTZ_CAMERA_ID);
|
|
return ids.length ? ids : [PTZ_CAMERA_ID];
|
|
}
|
|
|
|
function addSnapshotSubscription(socket, ids = [PTZ_CAMERA_ID]) {
|
|
if (!ids.includes(PTZ_CAMERA_ID)) return;
|
|
if (!snapshotSubscribers.has(PTZ_CAMERA_ID)) snapshotSubscribers.set(PTZ_CAMERA_ID, new Set());
|
|
snapshotSubscribers.get(PTZ_CAMERA_ID).add(socket.id);
|
|
if (!socketSnapshotSubscriptions.has(socket.id)) socketSnapshotSubscriptions.set(socket.id, new Set());
|
|
socketSnapshotSubscriptions.get(socket.id).add(PTZ_CAMERA_ID);
|
|
}
|
|
|
|
function removeSnapshotSubscriptions(socketId, ids = null) {
|
|
const bucket = socketSnapshotSubscriptions.get(socketId);
|
|
if (!bucket) return;
|
|
const idsToRemove = ids ? new Set(ids) : bucket;
|
|
idsToRemove.forEach((id) => {
|
|
const subscribers = snapshotSubscribers.get(id);
|
|
if (subscribers) {
|
|
subscribers.delete(socketId);
|
|
if (!subscribers.size) snapshotSubscribers.delete(id);
|
|
}
|
|
bucket.delete(id);
|
|
});
|
|
if (!bucket.size) {
|
|
socketSnapshotSubscriptions.delete(socketId);
|
|
snapshotLastSentBySocket.delete(socketId);
|
|
}
|
|
}
|
|
|
|
function sendSnapshotFrame(socket, buffer, ts) {
|
|
socket.emit('ptzCamera:snapshotFrame', { id: PTZ_CAMERA_ID, ts }, buffer);
|
|
}
|
|
|
|
function normalizeSocketArgs(firstArg, secondArg) {
|
|
/*
|
|
Socket.IO does not reserve a payload slot. If the browser emits only an ack
|
|
callback, the callback arrives as the first argument; if it emits no ack,
|
|
there is no callback at all. PTZ movement is sometimes fire-and-forget from
|
|
the shared rover control pipeline, so every handler needs the same small
|
|
normalizer before it calls back.
|
|
*/
|
|
if (typeof firstArg === 'function') {
|
|
return { payload: {}, cb: firstArg };
|
|
}
|
|
return {
|
|
payload: firstArg && typeof firstArg === 'object' ? firstArg : {},
|
|
cb: typeof secondArg === 'function' ? secondArg : () => {},
|
|
};
|
|
}
|
|
|
|
function claimTurn(socket) {
|
|
checkParticipationAccess(socket);
|
|
participation.claim(socket);
|
|
return getPublicState(socket);
|
|
}
|
|
function releaseTurn(socket) {
|
|
participation.release(socket.id);
|
|
return getPublicState(socket);
|
|
}
|
|
function acceptMotionIntent(socket, payload) {
|
|
requireOperator(socket);
|
|
return runtime.queueMotionIntent(payload, 'operator-input');
|
|
}
|
|
function operatorAction(method, socket, payload) {
|
|
requireOperator(socket);
|
|
const turn = participation.state.generation;
|
|
return runtime[method](payload, () => {
|
|
requireOperator(socket);
|
|
if (participation.state.generation !== turn) throw new Error('PTZ turn changed');
|
|
});
|
|
}
|
|
function presetAction(method, socket, payload) {
|
|
const authorize = () => method === 'removePreset' ? requirePresetAdmin(socket) : requirePtzUser(socket);
|
|
authorize();
|
|
return runtime[method](payload, authorize);
|
|
}
|
|
|
|
events.on('snapshot:frame', ({ buffer, ts }) => {
|
|
const subscribers = snapshotSubscribers.get(PTZ_CAMERA_ID);
|
|
if (!subscribers || !buffer) return;
|
|
subscribers.forEach((socketId) => {
|
|
const socket = io.sockets.sockets.get(socketId);
|
|
if (!socket) return;
|
|
const last = snapshotLastSentBySocket.get(socketId) || 0;
|
|
const now = ts || Date.now();
|
|
if (now - last < SNAPSHOT_STREAM_INTERVAL_MS) return;
|
|
snapshotLastSentBySocket.set(socketId, now);
|
|
sendSnapshotFrame(socket, buffer, ts);
|
|
});
|
|
});
|
|
|
|
events.on('snapshot:status', ({ error }) => {
|
|
const subscribers = snapshotSubscribers.get(PTZ_CAMERA_ID);
|
|
if (!subscribers) return;
|
|
subscribers.forEach((socketId) => {
|
|
const socket = io.sockets.sockets.get(socketId);
|
|
if (socket) socket.emit('ptzCamera:snapshotStatus', { id: PTZ_CAMERA_ID, error: error || null });
|
|
});
|
|
});
|
|
|
|
|
|
function reconcileAccess() {
|
|
participation.reconcile();
|
|
videoSessions.revokeWhere((info) => info.sourceType === 'ptz'
|
|
&& !canRequestLiveVideo(io.sockets.sockets.get(info.socketId)));
|
|
for (const socketId of socketSnapshotSubscriptions.keys()) {
|
|
const socket = io.sockets.sockets.get(socketId);
|
|
if (!socket || !passesMode(socket)) removeSnapshotSubscriptions(socketId);
|
|
}
|
|
}
|
|
modeEvents.on('change', reconcileAccess);
|
|
roleEvents.on('change', reconcileAccess);
|
|
verificationEvents.on('change', reconcileAccess);
|
|
|
|
registerSocketGateway({
|
|
io, isAdmin, participation, emitChange, normalizeSocketArgs, runPtzTurnAction,
|
|
acceptMotionIntent, passesMode, normalizeSnapshotIds, addSnapshotSubscription,
|
|
sendSnapshotFrame, removeSnapshotSubscriptions, getSnapshot: () => runtime.getSnapshot(),
|
|
getStatus: (socket) => { requirePtzUser(socket); return runtime.getStatus(); },
|
|
listPresets: (socket) => { requirePtzUser(socket); return runtime.listPresets(); },
|
|
gotoPreset: (socket, payload) => operatorAction('gotoPreset', socket, payload),
|
|
setSpotlight: (socket, payload) => operatorAction('setSpotlight', socket, payload),
|
|
setIr: (socket, payload) => operatorAction('setIr', socket, payload),
|
|
createPreset: (socket, payload) => presetAction('createPreset', socket, payload),
|
|
removePreset: (socket, payload) => presetAction('removePreset', socket, payload),
|
|
});
|
|
function startRuntime() {
|
|
if (enabled) runtime.initialize().catch((err) => logger.warn('PTZ initialization failed', { error: err.message }));
|
|
}
|
|
registerConfigurationHandler('ptzCamera', (nextCameraConfig = {}) => {
|
|
participation.clear('configuration-change');
|
|
const startAfter = runtime.stop();
|
|
cameraConfig = nextCameraConfig;
|
|
enabled = Boolean(cameraConfig.enabled);
|
|
runtime = createCameraRuntime({ cameraConfig, logger, events, onChange: emitChange, getSocketLabel, startAfter });
|
|
reconcileAccess();
|
|
startRuntime();
|
|
});
|
|
startRuntime();
|
|
|
|
module.exports = {
|
|
PTZ_CAMERA_ID,
|
|
PTZ_STREAM_PATH,
|
|
ptzCameraEvents: events,
|
|
getPublicState,
|
|
getOperatingModeDisplay,
|
|
prepareParticipation,
|
|
checkParticipationAccess,
|
|
claimTurn,
|
|
releaseTurn,
|
|
getParticipantSocketIds,
|
|
canSpeakThroughPtz,
|
|
speakText,
|
|
canRequestLiveVideo,
|
|
disableEmittersForIdle: () => runtime.disableEmittersForIdle(),
|
|
getReplaySource: () => enabled && isReplayEnabled()
|
|
? { type: 'ptz', id: PTZ_CAMERA_ID, label: cameraConfig.name || 'PTZ Camera' }
|
|
: null,
|
|
getReplayWorkerSources: () => {
|
|
/*
|
|
PTZ replay uses two internal workers from the same MediaMTX path. The
|
|
selectable replay source stays "ptz:ptz-camera", while the segment engine
|
|
records video and audio separately so replayBuilder can mix PTZ microphone
|
|
audio the same way it already mixes rover audio.
|
|
*/
|
|
if (!enabled || !isReplayEnabled()) return [];
|
|
const inputUrl = `rtsp://127.0.0.1:8554/${encodeURIComponent(PTZ_STREAM_PATH)}`;
|
|
const label = cameraConfig.name || 'PTZ Camera';
|
|
return [
|
|
{
|
|
id: PTZ_CAMERA_ID,
|
|
sourceType: 'ptz',
|
|
kind: 'video',
|
|
label,
|
|
inputUrl,
|
|
},
|
|
{
|
|
id: `${PTZ_CAMERA_ID}-audio`,
|
|
sourceType: 'ptz',
|
|
sourceId: PTZ_CAMERA_ID,
|
|
kind: 'audio',
|
|
label: `${label} audio`,
|
|
inputUrl,
|
|
},
|
|
];
|
|
},
|
|
getReplayWorkerSource: () => {
|
|
const [videoSource] = module.exports.getReplayWorkerSources();
|
|
return videoSource || null;
|
|
},
|
|
};
|