Files
MultiRoombaRover/server/src/services/ptzCameraService/index.js
T
2026-07-11 19:49:13 -04:00

1172 lines
38 KiB
JavaScript

// PTZ Camera Service
// Purpose: Owns the single Reolink TrackMix PTZ camera integration, including queueing, ONVIF control, Reolink-only light controls, stream publishing, snapshots, and session state.
// Scope: This is intentionally a one-camera feature, not a generic ONVIF camera framework.
const EventEmitter = require('events');
const fs = require('fs/promises');
const path = require('path');
const { spawn } = require('child_process');
const { Cam } = require('onvif');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('ptzCamera');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const { isVerified } = require('../verificationService');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const roverManager = require('../roverManager');
const assignmentService = require('../assignmentService');
const videoSessions = require('../videoSessions');
const PTZ_CAMERA_ID = 'ptz-camera';
const PTZ_STREAM_PATH = 'ptz-camera';
const DEFAULT_ONVIF_PORT = 8000;
const DEFAULT_PROFILE_TOKEN = '003';
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
// PTZ is a normal replay source now, so capture should be on unless the feature
// explicitly disables replay for the camera.
const DEFAULT_REPLAY_ENABLED = true;
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const SNAPSHOT_POLL_MS = 300;
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
const PUBLISHER_STDERR_SYNC_MS = 10000;
const events = new EventEmitter();
const config = loadConfig();
const cameraConfig = config.ptzCamera || {};
const enabled = isFeatureEnabled('ptzCamera');
const state = {
initialized: false,
initializing: false,
error: null,
profileToken: String(cameraConfig.profileToken || DEFAULT_PROFILE_TOKEN),
rtspUri: null,
streamPath: PTZ_STREAM_PATH,
operatorSocketId: null,
queue: [],
deadline: null,
blocked: null,
status: null,
light: null,
ir: null,
publisher: {
running: false,
pid: null,
startedAt: null,
restartAt: null,
restartCount: 0,
exitCode: null,
exitSignal: null,
exitedAt: null,
lastStderr: '',
progress: null,
lastEvent: 'idle',
},
};
let onvifCam = null;
let reolinkClient = null;
let reolinkModulePromise = null;
let turnTimer = null;
let publisherProcess = null;
let publisherRestartTimer = null;
let publisherStderrSyncTimer = null;
let snapshotTimer = null;
let spotlightVerifyTimer = null;
let vendorStatePromise = Promise.resolve();
let lastSnapshotState = null;
const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map();
const snapshotLastSentBySocket = new Map();
function emitChange(reason = 'change') {
events.emit('change', { reason, state: getPublicState() });
}
function schedulePublisherStateSync(reason = 'publisher') {
/*
ffmpeg can print many warning/progress lines in bursts. Keep the latest text
in state immediately, but debounce session sync so one noisy transcoder does
not force every connected client to resync for each stderr chunk.
*/
if (publisherStderrSyncTimer) return;
publisherStderrSyncTimer = setTimeout(() => {
publisherStderrSyncTimer = null;
emitChange(reason);
}, PUBLISHER_STDERR_SYNC_MS);
}
function updatePublisherState(patch = {}, reason = 'publisher') {
state.publisher = {
...(state.publisher || {}),
...patch,
};
emitChange(reason);
}
function parsePublisherProgressLine(line) {
/*
ffmpeg's "-progress pipe:2" emits simple key=value telemetry on stderr.
Warning lines also arrive on stderr, so keep parsing narrow: only accept the
known progress keys and let everything else remain user-visible stderr.
This gives the UI enough signal to tell whether the transcoder is actually
falling behind without flooding normal server logs.
*/
const match = String(line || '').match(/^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$/);
if (!match) return false;
const [, key, rawValue] = match;
const allowed = new Set([
'frame',
'fps',
'stream_0_0_q',
'bitrate',
'total_size',
'out_time_us',
'out_time_ms',
'out_time',
'dup_frames',
'drop_frames',
'speed',
'progress',
]);
if (!allowed.has(key)) return false;
state.publisher = {
...(state.publisher || {}),
progress: {
...(state.publisher?.progress || {}),
[key]: rawValue,
updatedAt: Date.now(),
},
lastEvent: 'progress',
};
return true;
}
function handlePublisherStderr(chunk) {
const text = String(chunk || '').trim();
if (!text) return;
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const warningLines = [];
lines.forEach((line) => {
if (!parsePublisherProgressLine(line)) warningLines.push(line);
});
if (warningLines.length) {
state.publisher = {
...(state.publisher || {}),
lastStderr: warningLines.join('\n').slice(-1000),
lastEvent: 'stderr',
};
}
schedulePublisherStateSync(warningLines.length ? 'publisher-stderr' : 'publisher-progress');
}
function clampUnit(value) {
const number = Number(value) || 0;
return Math.max(-1, Math.min(1, number));
}
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 spotlightCameraStateForLogicalOn(logicalOn) {
return Boolean(logicalOn) ? 1 : 0;
}
function isSpotlightOn(light = {}) {
const raw = light?.state;
let rawOn = false;
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
rawOn = !['', '0', 'off', 'false'].includes(normalized);
} else {
rawOn = Boolean(Number(raw));
}
return rawOn;
}
function normalizeSpotlightState(light) {
if (!light || typeof light !== 'object') return light || null;
return { ...light, on: isSpotlightOn(light) };
}
function normalizeSpotlightPayloadState(rawState) {
/*
Socket payloads can arrive as booleans, numbers, or strings depending on
which control path produced them. Boolean("0") is true in JavaScript, so do
an explicit conversion here before building the Reolink payload.
*/
if (typeof rawState === 'string') {
const normalized = rawState.trim().toLowerCase();
if (['1', 'on', 'true', 'yes'].includes(normalized)) return true;
if (['0', 'off', 'false', 'no'].includes(normalized)) return false;
}
return Boolean(Number(rawState));
}
function normalizeIrState(rawState) {
/*
Reolink accepts exactly Auto, On, and Off for this camera's IR LED control.
Normalize UI payloads at the server boundary so keyboard, mobile, and any
future direct socket callers all hit the same camera API contract.
*/
const normalized = String(rawState || '').trim().toLowerCase();
if (normalized === 'on' || normalized === '1' || normalized === 'true') return 'On';
if (normalized === 'off' || normalized === '0' || normalized === 'false') return 'Off';
return 'Auto';
}
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 getPublicState(socket = null) {
const socketId = socket?.id || null;
const queue = state.queue.map((id) => ({
socketId: id,
label: getSocketLabel(id),
}));
return {
enabled,
id: PTZ_CAMERA_ID,
name: cameraConfig.name || 'PTZ Camera',
initialized: state.initialized,
error: state.error,
streamPath: state.streamPath,
operatorSocketId: state.operatorSocketId,
operatorLabel: getSocketLabel(state.operatorSocketId),
queue,
deadline: state.deadline,
blocked: state.blocked,
status: state.status,
light: state.light,
ir: state.ir,
publisher: state.publisher,
isOperator: Boolean(socketId && state.operatorSocketId === socketId),
queuedPosition: socketId ? state.queue.indexOf(socketId) + 1 || null : null,
canUse: socket ? canUsePtzFeature(socket) : false,
};
}
function callOnvif(method, options = {}) {
return new Promise((resolve, reject) => {
if (!onvifCam || typeof onvifCam[method] !== 'function') {
reject(new Error('ONVIF camera is not ready'));
return;
}
onvifCam[method](options, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
function connectOnvif() {
return new Promise((resolve, reject) => {
const cam = new Cam({
hostname: cameraConfig.host,
username: cameraConfig.username,
password: cameraConfig.password,
port: Number(cameraConfig.onvifPort) || DEFAULT_ONVIF_PORT,
timeout: 10000,
}, function handleConnect(err) {
if (err) reject(err);
else resolve(this);
});
return cam;
});
}
async function getStreamUriForProfile(cam) {
const profileToken = String(cameraConfig.profileToken || DEFAULT_PROFILE_TOKEN);
return new Promise((resolve, reject) => {
cam.getStreamUri({ profileToken, protocol: 'RTSP' }, (err, data) => {
if (err) reject(err);
else resolve(data?.uri || data?.Uri || '');
});
});
}
function addCredentialsToRtsp(rawUri) {
const parsed = new URL(rawUri);
if (!parsed.username) parsed.username = cameraConfig.username;
if (!parsed.password) parsed.password = cameraConfig.password;
return parsed.toString();
}
function stopPublisher() {
if (publisherRestartTimer) {
clearTimeout(publisherRestartTimer);
publisherRestartTimer = null;
}
if (publisherProcess) {
try {
publisherProcess.kill('SIGTERM');
} catch {}
publisherProcess = null;
}
updatePublisherState({
running: false,
pid: null,
restartAt: null,
lastEvent: 'stopped',
}, 'publisher-stop');
}
function startPublisher() {
if (!enabled || !state.rtspUri || publisherProcess) return;
const input = addCredentialsToRtsp(state.rtspUri);
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`;
/*
The full-quality autotrack profile is H265, which is the right camera-side
feed but has been unreliable through browser WHEP playback. Re-encoding is
intentionally kept here, at the single camera publisher boundary, so the
rest of the video auth/session/UI code still sees one normal MediaMTX path.
The camera audio is AAC LC at 16 kHz mono. Keep it inline with the video so
the PTZ camera remains one MediaMTX/WHEP source, but transcode it to Opus
because that is the WebRTC-friendly audio codec browsers should negotiate
through MediaMTX. This avoids creating a rover-style separate audio stream
for a camera that already provides synchronized audio in the RTSP feed.
These encoder settings trade compression efficiency for control latency:
ultrafast avoids deep analysis, zerolatency disables x264 buffering, bf=0
removes B-frames, and the 20-frame GOP matches the camera's observed 20fps
autotrack stream so the browser gets frequent keyframes without forcing a
huge bitrate spike. The explicit x264 params turn off lookahead buffering
that is useful for compression quality but harmful when the camera is being
driven live. Sliced threads allow x264 to keep some parallelism without
waiting on future frames the way normal frame-threading can.
Keep RTSP demuxing conservative here. More aggressive "drop stale frames"
flags caused this camera stream to freeze after running for a while, so the
safer latency knob is to keep the encoder light and avoid building delay
inside x264 itself.
The mpegts muxer can also hold packets briefly before writing them to SRT.
flush_packets/muxdelay/muxpreload are output-side latency knobs; they do not
ask the camera or demuxer to discard frames, so they are a safer next step
than the stale-frame dropping experiments that made the Reolink feed freeze.
*/
const proc = spawn('ffmpeg', [
'-hide_banner',
'-loglevel',
'warning',
'-nostdin',
'-progress',
'pipe:2',
'-stats_period',
'2',
'-fflags',
'nobuffer',
'-flags',
'low_delay',
'-rtsp_transport',
'tcp',
'-i',
input,
'-map',
'0:v:0',
'-map',
'0:a:0',
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-tune',
'zerolatency',
'-threads',
'8',
'-x264-params',
'sliced-threads=1:sync-lookahead=0:rc-lookahead=0:keyint=20:min-keyint=20:scenecut=0',
'-bf',
'0',
'-g',
'20',
'-keyint_min',
'20',
'-sc_threshold',
'0',
'-pix_fmt',
'yuv420p',
'-c:a',
'libopus',
'-application',
'lowdelay',
'-frame_duration',
'10',
'-b:a',
'32k',
'-ac',
'1',
'-ar',
'48000',
'-strict',
'-2',
'-flush_packets',
'1',
'-muxdelay',
'0',
'-muxpreload',
'0',
'-f',
'mpegts',
output,
], { stdio: ['ignore', 'ignore', 'pipe'] });
publisherProcess = proc;
updatePublisherState({
running: true,
pid: proc.pid || null,
startedAt: Date.now(),
restartAt: null,
exitCode: null,
exitSignal: null,
exitedAt: null,
lastEvent: 'started',
}, 'publisher-start');
proc.stderr.on('data', handlePublisherStderr);
proc.on('exit', (code, signal) => {
if (publisherProcess === proc) publisherProcess = null;
logger.warn('publisher exited', { code, signal });
const restartAt = enabled && state.rtspUri ? Date.now() + 1500 : null;
updatePublisherState({
running: false,
pid: null,
restartAt,
restartCount: Number(state.publisher?.restartCount || 0) + (restartAt ? 1 : 0),
exitCode: code,
exitSignal: signal,
exitedAt: Date.now(),
lastEvent: restartAt ? 'restarting' : 'exited',
}, 'publisher-exit');
if (enabled && state.rtspUri) {
publisherRestartTimer = setTimeout(() => {
publisherRestartTimer = null;
startPublisher();
}, 1500);
}
});
logger.info('Started PTZ stream publisher', { streamPath: PTZ_STREAM_PATH, encoder: 'libx264' });
}
async function ensureReolinkClient() {
if (reolinkClient) return reolinkClient;
/*
reolink-nvr-api is published as an ESM-only package. This server is still
CommonJS, so a top-level require() fails before the service can even start.
Dynamic import keeps the server bootable and only loads the vendor SDK when
spotlight or IR state is actually queried.
*/
if (!reolinkModulePromise) {
reolinkModulePromise = import('reolink-nvr-api');
}
const { ReolinkClient } = await reolinkModulePromise;
reolinkClient = new ReolinkClient({
host: cameraConfig.host,
username: cameraConfig.username,
password: cameraConfig.password,
mode: 'long',
insecure: true,
timeout: 10000,
});
await reolinkClient.login();
return reolinkClient;
}
async function refreshVendorState() {
if (!enabled) return;
const client = await ensureReolinkClient();
const [white, ir] = await Promise.all([
client.api('GetWhiteLed', { channel: 0 }).catch((err) => ({ error: err.message })),
client.api('GetIrLights', { channel: 0 }).catch((err) => ({ error: err.message })),
]);
state.light = normalizeSpotlightState(white?.WhiteLed || white || null);
state.ir = ir?.IrLights || ir || null;
}
async function refreshSpotlightState() {
const client = await ensureReolinkClient();
const white = await client.api('GetWhiteLed', { channel: 0 });
state.light = normalizeSpotlightState(white?.WhiteLed || white || null);
emitChange('light');
return state.light;
}
function scheduleSpotlightVerification() {
/*
This camera acknowledges SetWhiteLed before GetWhiteLed catches up. A read
immediately after a successful write returns the old value for roughly one
second, which made the UI appear inverted or flaky. Replace any pending
verification with one delayed read so rapid toggles settle on the newest
requested state instead of racing stale camera state back into the session.
*/
if (spotlightVerifyTimer) {
clearTimeout(spotlightVerifyTimer);
spotlightVerifyTimer = null;
}
spotlightVerifyTimer = setTimeout(() => {
spotlightVerifyTimer = null;
serializeVendorState(() => refreshSpotlightState()).catch((err) => {
logger.warn('spotlight verification failed', { error: err.message });
});
}, SPOTLIGHT_VERIFY_DELAY_MS);
}
function serializeVendorState(operation) {
/*
The Reolink HTTP API can return stale light state when reads and writes are
overlapped. Keep spotlight/IR changes in one narrow queue so a button mash
becomes ordered camera operations instead of competing Get/Set requests.
*/
vendorStatePromise = vendorStatePromise
.catch(() => {})
.then(operation);
return vendorStatePromise;
}
async function initialize() {
if (!enabled || state.initialized || state.initializing) return;
state.initializing = true;
try {
onvifCam = await connectOnvif();
state.rtspUri = await getStreamUriForProfile(onvifCam);
await refreshVendorState();
state.initialized = true;
state.error = null;
startPublisher();
startSnapshotPolling();
logger.info('PTZ camera initialized', {
host: cameraConfig.host,
profileToken: state.profileToken,
streamPath: PTZ_STREAM_PATH,
});
} catch (err) {
state.error = err.message || String(err);
logger.warn('PTZ camera initialization failed', { error: state.error });
} finally {
state.initializing = false;
emitChange('initialize');
}
}
function clearTurnTimer() {
if (turnTimer) clearTimeout(turnTimer);
turnTimer = null;
}
function removeFromQueue(socketId) {
state.queue = state.queue.filter((id) => id !== socketId);
}
function buildDockRequiredPayload(socket, leave) {
/*
PTZ must not become an escape hatch for abandoning the last undocked rover.
Keep the payload shape shared between immediate claim rejection and stale
queue cleanup so the browser gets one consistent dock-required event.
*/
return {
socketId: socket.id,
label: getSocketLabel(socket.id),
roverId: leave.currentId || null,
message: leave.message,
until: null,
};
}
function revokeOperator(reason = 'release') {
if (!state.operatorSocketId) return;
const previous = state.operatorSocketId;
state.operatorSocketId = null;
state.deadline = null;
clearTurnTimer();
videoSessions.revokeWhere((info) => info.socketId === previous && info.sourceType === 'ptz');
callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {});
events.emit('operator', { socketId: previous, action: 'release', reason });
}
function activateOperator(socket) {
revokeOperator('handoff');
removeFromQueue(socket.id);
/*
PTZ operation is mutually exclusive with rover ownership. Releasing through
assignmentService preserves the existing queue/control cleanup rules instead
of directly mutating rover manager state.
*/
roverManager.getRoversForSocket(socket.id).forEach((roverId) => {
assignmentService.forceRelease(roverId, socket.id);
});
state.operatorSocketId = socket.id;
state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
socket.emit('ptzCamera:turn', { status: 'active', deadline: state.deadline });
events.emit('operator', { socketId: socket.id, action: 'active' });
emitChange('operator-active');
}
function handleTurnDeadline() {
turnTimer = null;
if (!state.operatorSocketId) return;
if (state.queue.length > 0) {
revokeOperator('turn-expired');
advanceQueue('turn-expired');
return;
}
/*
A turn timer only matters when somebody else is waiting. If the operator is
alone, keep them on the PTZ camera and roll the deadline forward so the UI
stays coherent without kicking out the only active viewer.
*/
state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
emitChange('turn-extended-empty-queue');
}
function advanceQueue(reason = 'advance') {
if (state.operatorSocketId || !state.queue.length) {
emitChange(reason);
return;
}
const nextId = state.queue[0];
const socket = io.sockets.sockets.get(nextId);
if (!socket || !canUsePtzFeature(socket)) {
removeFromQueue(nextId);
advanceQueue('drop-invalid');
return;
}
const leave = roverManager.canLeaveCurrentRover(socket);
if (!leave.ok) {
/*
claim() blocks this before queue entry, but this defensive check handles
stale state: a user can dock, join the queue, then undock again before
their PTZ turn arrives. In that case they are removed instead of holding a
PTZ queue slot while still responsible for an undocked rover.
*/
removeFromQueue(socket.id);
socket.emit('ptzCamera:dockRequired', buildDockRequiredPayload(socket, leave));
emitChange('drop-dock-required');
advanceQueue('drop-dock-required');
return;
}
activateOperator(socket);
}
async function claim(socket) {
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
await initialize();
if (!state.initialized) throw new Error(state.error || 'PTZ camera is not ready');
if (state.operatorSocketId === socket.id) return getPublicState(socket);
const leave = roverManager.canLeaveCurrentRover(socket);
if (!leave.ok) {
const payload = buildDockRequiredPayload(socket, leave);
socket.emit('ptzCamera:dockRequired', payload);
throw new Error(leave.message);
}
if (state.operatorSocketId) {
if (!state.queue.includes(socket.id)) state.queue.push(socket.id);
emitChange('queue-join');
return getPublicState(socket);
}
if (!state.queue.includes(socket.id)) state.queue.unshift(socket.id);
advanceQueue('claim');
return getPublicState(socket);
}
async function release(socket) {
if (state.operatorSocketId === socket.id || isAdmin(socket)) {
revokeOperator('manual-release');
advanceQueue('manual-release');
} else {
removeFromQueue(socket.id);
emitChange('queue-leave');
}
return getPublicState(socket);
}
function requireOperator(socket) {
if (!enabled) throw new Error('PTZ camera disabled');
if (!passesMode(socket)) throw new Error('Not authorized for PTZ camera');
if (!socket || state.operatorSocketId !== socket.id) throw new Error('Not the PTZ operator');
}
async function move(socket, payload = {}) {
requireOperator(socket);
await initialize();
const x = clampUnit(payload.pan ?? payload.x);
const y = clampUnit(payload.tilt ?? payload.y);
const zoom = clampUnit(payload.zoom);
await callOnvif('continuousMove', {
profileToken: state.profileToken,
x,
y,
zoom,
timeout: 1000,
});
return { ok: true };
}
async function stop(socket) {
requireOperator(socket);
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true });
return { ok: true };
}
async function getStatus(socket) {
if (!passesMode(socket)) throw new Error('Not authorized for PTZ camera');
await initialize();
const status = await callOnvif('getStatus', { profileToken: state.profileToken });
state.status = status || null;
emitChange('status');
return state.status;
}
async function setSpotlight(socket, payload = {}) {
requireOperator(socket);
return serializeVendorState(async () => {
const client = await ensureReolinkClient();
let current = state.light ? normalizeSpotlightState(state.light) : null;
if (payload.state === undefined && !current) {
/*
Toggle requests need a base state. Normal button paths send an explicit
state, so this read only happens for rare generic toggle callers or
startup races before the initial vendor state has arrived.
*/
current = await refreshSpotlightState();
}
const logicalOn = payload.state === undefined
? !isSpotlightOn(current || {})
: normalizeSpotlightPayloadState(payload.state);
const cameraState = spotlightCameraStateForLogicalOn(logicalOn);
const cameraPayload = {
channel: 0,
state: cameraState,
};
const next = {
...(current || {}),
...cameraPayload,
on: logicalOn,
};
if (Number.isFinite(Number(payload.bright))) {
const bright = Math.max(0, Math.min(100, Number(payload.bright)));
cameraPayload.bright = bright;
next.bright = bright;
}
/*
The camera accepts a minimal WhiteLed payload and reports success before
GetWhiteLed reflects the new state. Send only the fields we intend to
change, then keep the optimistic state until the delayed verification read
has a real chance to observe the update.
*/
state.light = next;
emitChange('light-pending');
await client.api('SetWhiteLed', { WhiteLed: cameraPayload });
scheduleSpotlightVerification();
return state.light;
});
}
async function setIr(socket, payload = {}) {
requireOperator(socket);
return serializeVendorState(async () => {
const nextState = normalizeIrState(payload.state);
const client = await ensureReolinkClient();
/*
The camera requires channel inside IrLights. Without it, SetIrLights
returns param error (-4), while the optimistic local state makes the UI
look like the command worked. Keep the optimistic state, but send the
minimal payload the camera actually accepts.
*/
state.ir = { ...(state.ir || {}), channel: 0, state: nextState };
emitChange('ir-pending');
await client.api('SetIrLights', { IrLights: { channel: 0, state: nextState } });
await refreshVendorState();
emitChange('ir');
return state.ir;
});
}
async function disableEmittersForIdle() {
/*
Idle cleanup is a server-owned safety action, not a user control action, so
it intentionally does not go through requireOperator(). If nobody is using
the camera, the system still needs a way to leave every camera-side emitter
in a known off state.
*/
if (!enabled) {
return { action: 'disablePtzEmitters', skipped: true, reason: 'ptzDisabled' };
}
await initialize();
if (!state.initialized) {
return {
action: 'disablePtzEmitters',
success: false,
error: state.error || 'PTZ camera is not ready',
};
}
return serializeVendorState(async () => {
const client = await ensureReolinkClient();
const lightPayload = { channel: 0, state: spotlightCameraStateForLogicalOn(false) };
const irPayload = { channel: 0, state: normalizeIrState('off') };
const failures = [];
/*
Set the public state before the API calls finish so the UI immediately
reflects the idle policy. If a camera call fails, the result still records
that failure and the next vendor refresh can correct the optimistic state.
*/
state.light = normalizeSpotlightState({
...(state.light || {}),
...lightPayload,
on: false,
});
state.ir = {
...(state.ir || {}),
...irPayload,
};
emitChange('idle-emitters-off-pending');
try {
await client.api('SetWhiteLed', { WhiteLed: lightPayload });
} catch (err) {
failures.push({ control: 'spotlight', error: err.message });
}
try {
await client.api('SetIrLights', { IrLights: irPayload });
} catch (err) {
failures.push({ control: 'ir', error: err.message });
}
/*
Read back once after the writes so stale optimistic state does not linger
forever. The existing spotlight button path delays verification because it
is user-facing and frequently toggled; idle fires rarely, so one ordered
refresh keeps the final state simple.
*/
try {
await refreshVendorState();
} catch (err) {
failures.push({ control: 'refresh', error: err.message });
}
emitChange('idle-emitters-off');
return {
action: 'disablePtzEmitters',
success: failures.length === 0,
failures,
};
});
}
function canRequestLiveVideo(socket) {
if (!enabled || !passesMode(socket)) return false;
if (state.operatorSocketId === socket?.id) return true;
if (isAdmin(socket) || isLockdownAdmin(socket)) return true;
return isLocalNetwork(getSocketIp(socket));
}
function getSnapshotPath() {
return path.join(SNAPSHOT_DIR, `${PTZ_STREAM_PATH}.jpg`);
}
async function pollSnapshot() {
try {
const filePath = getSnapshotPath();
const stats = await fs.stat(filePath);
if (lastSnapshotState?.mtimeMs && stats.mtimeMs <= lastSnapshotState.mtimeMs) return;
const buffer = await fs.readFile(filePath);
lastSnapshotState = { frame: buffer, ts: stats.mtimeMs || Date.now(), error: null, mtimeMs: stats.mtimeMs };
events.emit('snapshot:frame', { id: PTZ_CAMERA_ID, buffer, ts: lastSnapshotState.ts });
} catch (err) {
lastSnapshotState = {
...(lastSnapshotState || {}),
error: err.code === 'ENOENT' ? 'Snapshot missing' : err.message,
};
events.emit('snapshot:status', { id: PTZ_CAMERA_ID, error: lastSnapshotState.error });
}
}
function startSnapshotPolling() {
if (snapshotTimer) return;
snapshotTimer = setInterval(() => {
pollSnapshot().catch((err) => logger.warn('snapshot poll failed', { error: err.message }));
}, SNAPSHOT_POLL_MS);
}
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 : () => {},
};
}
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 registerSocketHandlers() {
io.on('connection', (socket) => {
socket.on('ptzCamera:claim', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, state: await claim(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, state: await release(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:move', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await move(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:stop', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await stop(socket));
} 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: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);
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', () => {
if (state.operatorSocketId === socket.id) {
revokeOperator('disconnect');
advanceQueue('disconnect');
}
removeFromQueue(socket.id);
removeSnapshotSubscriptions(socket.id);
emitChange('disconnect');
});
});
}
modeEvents.on('change', (mode) => {
if (mode !== MODES.LOCKDOWN) return;
if (state.operatorSocketId) {
const socket = io.sockets.sockets.get(state.operatorSocketId);
if (!socket || !isLockdownAdmin(socket)) revokeOperator('lockdown');
}
state.queue = state.queue.filter((socketId) => {
const socket = io.sockets.sockets.get(socketId);
return socket && isLockdownAdmin(socket);
});
videoSessions.revokeWhere((info) => {
if (info.sourceType !== 'ptz') return false;
const socket = io.sockets.sockets.get(info.socketId);
return !socket || !isLockdownAdmin(socket);
});
Array.from(socketSnapshotSubscriptions.keys()).forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket || !isLockdownAdmin(socket)) removeSnapshotSubscriptions(socketId);
});
emitChange('lockdown');
});
registerSocketHandlers();
if (enabled) {
initialize();
}
module.exports = {
PTZ_CAMERA_ID,
PTZ_STREAM_PATH,
ptzCameraEvents: events,
getPublicState,
canRequestLiveVideo,
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 = `srt://127.0.0.1:9000?streamid=read:${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;
},
};