This commit is contained in:
legop3
2026-07-10 23:20:09 -04:00
parent ee393adc8e
commit d777e3a48e
27 changed files with 1363 additions and 162 deletions
+13
View File
@@ -128,6 +128,19 @@ roomCameras:
url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
ptzCamera:
enabled: false
name: "PTZ Camera"
host: "192.168.0.8"
onvifPort: 8000
username: "admin"
password: "REPLACE_WITH_CAMERA_PASSWORD"
# The Reolink TrackMix autotrack profile was token 003 during commissioning.
# Keeping this configurable lets firmware/profile resets be fixed without code
# changes while the integration still remains a single-camera feature.
profileToken: "003"
turnDurationMs: 300000
kinect:
enabled: false
# Capture requests are global across 3d/color so one person cannot spam room
+1
View File
@@ -26,6 +26,7 @@ require('./src/services/overseerControlService');
require('./src/services/globalObjectiveService');
require('./src/services/serverControlService');
require('./src/services/videoSessions');
require('./src/services/ptzCameraService');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
require('./src/services/roomCameraService');
+2
View File
@@ -19,6 +19,8 @@
"morgan": "^1.10.0",
"obscenity": "^0.4.6",
"ollama": "^0.6.3",
"onvif": "^0.8.1",
"reolink-nvr-api": "^0.3.0",
"sharp": "^0.33.5",
"socket.io": "^4.7.5",
"uuid": "^9.0.1",
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-htN8tzuK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfZ_TWsb.css">
<script type="module" crossorigin src="/assets/index-BFbU1lYU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css">
</head>
<body>
<div id="root"></div>
+7
View File
@@ -49,6 +49,7 @@ function buildFeatureFlags(config = loadConfig()) {
const barcodeGamesConfig = config.barcodeGames || {};
const socialsConfig = config.socials || {};
const interInstanceConfig = config.interInstance || {};
const ptzCameraConfig = config.ptzCamera || {};
const homeAssistant = Boolean(
asBoolean(homeAssistantConfig.enabled) &&
asTrimmedString(homeAssistantConfig.url) &&
@@ -80,6 +81,12 @@ function buildFeatureFlags(config = loadConfig()) {
),
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
interInstance: asBoolean(interInstanceConfig.enabled),
ptzCamera: Boolean(
asBoolean(ptzCameraConfig.enabled) &&
asTrimmedString(ptzCameraConfig.host) &&
asTrimmedString(ptzCameraConfig.username) &&
asTrimmedString(ptzCameraConfig.password),
),
};
}
@@ -0,0 +1,665 @@
// 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 { ReolinkClient } = require('reolink-nvr-api');
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;
const DOCK_GRACE_MS = 60 * 1000;
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const SNAPSHOT_POLL_MS = 300;
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
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,
};
let onvifCam = null;
let reolinkClient = null;
let turnTimer = null;
let blockedTimer = null;
let publisherProcess = null;
let publisherRestartTimer = null;
let snapshotTimer = null;
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 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 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,
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;
}
}
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)}`;
/*
MediaMTX already treats SRT publishers as trusted local media producers.
Copying the autotrack stream keeps the full-resolution camera feed intact;
if browser H.265 support becomes a problem later, this is the one place to
add a transcode without changing PTZ ownership or UI code.
*/
const proc = spawn('ffmpeg', [
'-hide_banner',
'-loglevel',
'warning',
'-nostdin',
'-rtsp_transport',
'tcp',
'-i',
input,
'-c',
'copy',
'-f',
'mpegts',
output,
], { stdio: ['ignore', 'ignore', 'pipe'] });
publisherProcess = proc;
proc.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (text) logger.warn('publisher stderr', { text: text.slice(0, 500) });
});
proc.on('exit', (code, signal) => {
if (publisherProcess === proc) publisherProcess = null;
logger.warn('publisher exited', { code, signal });
if (enabled && state.rtspUri) {
publisherRestartTimer = setTimeout(() => {
publisherRestartTimer = null;
startPublisher();
}, 1500);
}
});
logger.info('Started PTZ stream publisher', { streamPath: PTZ_STREAM_PATH });
}
async function ensureReolinkClient() {
if (reolinkClient) return reolinkClient;
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 = white?.WhiteLed || white || null;
state.ir = ir?.IrLights || ir || null;
}
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 clearBlockedTimer() {
if (blockedTimer) clearTimeout(blockedTimer);
blockedTimer = null;
}
function removeFromQueue(socketId) {
state.queue = state.queue.filter((id) => id !== socketId);
}
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(() => {
revokeOperator('turn-expired');
advanceQueue('turn-expired');
}, getTurnDurationMs());
socket.emit('ptzCamera:turn', { status: 'active', deadline: state.deadline });
events.emit('operator', { socketId: socket.id, action: 'active' });
emitChange('operator-active');
}
function advanceQueue(reason = 'advance') {
clearBlockedTimer();
state.blocked = null;
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) {
/*
A queued user may reach the camera while still being the last person on an
undocked rover. Hold their queue slot briefly so they can dock; if they do
not satisfy the shared rover-leave rule, rotate them to the back and let
the next person try.
*/
state.blocked = {
socketId: socket.id,
label: getSocketLabel(socket.id),
roverId: leave.currentId || null,
message: leave.message,
until: Date.now() + DOCK_GRACE_MS,
};
socket.emit('ptzCamera:dockRequired', state.blocked);
blockedTimer = setTimeout(() => {
const [blockedId] = state.queue.splice(0, 1);
if (blockedId) state.queue.push(blockedId);
state.blocked = null;
advanceQueue('dock-grace-expired');
}, DOCK_GRACE_MS);
emitChange('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);
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);
const client = await ensureReolinkClient();
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed;
const next = {
...current,
channel: 0,
state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)),
};
if (Number.isFinite(Number(payload.bright))) {
next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
}
await client.api('SetWhiteLed', { WhiteLed: next });
await refreshVendorState();
emitChange('light');
return state.light;
}
async function setIr(socket, payload = {}) {
requireOperator(socket);
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
const client = await ensureReolinkClient();
await client.api('SetIrLights', { IrLights: { state: nextState } });
await refreshVendorState();
emitChange('ir');
return state.ir;
}
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 addSnapshotSubscription(socket) {
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) {
const bucket = socketSnapshotSubscriptions.get(socketId);
if (!bucket) return;
bucket.forEach((id) => {
const subscribers = snapshotSubscribers.get(id);
if (subscribers) {
subscribers.delete(socketId);
if (!subscribers.size) snapshotSubscribers.delete(id);
}
});
socketSnapshotSubscriptions.delete(socketId);
snapshotLastSentBySocket.delete(socketId);
}
function sendSnapshotFrame(socket, buffer, ts) {
socket.emit('ptzCamera:snapshotFrame', { id: PTZ_CAMERA_ID, ts }, buffer);
}
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 (_payload = {}, cb = () => {}) => {
try {
cb({ ok: true, state: await claim(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:release', async (_payload = {}, cb = () => {}) => {
try {
cb({ ok: true, state: await release(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:move', async (payload = {}, cb = () => {}) => {
try {
cb(await move(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:stop', async (_payload = {}, cb = () => {}) => {
try {
cb(await stop(socket));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:status', async (_payload = {}, cb = () => {}) => {
try {
cb({ ok: true, status: await getStatus(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:spotlight', async (payload = {}, cb = () => {}) => {
try {
cb({ ok: true, light: await setSpotlight(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:ir', async (payload = {}, cb = () => {}) => {
try {
cb({ ok: true, ir: await setIr(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotSubscribe', (_payload = {}, cb = () => {}) => {
try {
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
addSnapshotSubscription(socket);
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
cb({ ok: true, subscribed: [PTZ_CAMERA_ID] });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotUnsubscribe', () => {
removeSnapshotSubscriptions(socket.id);
});
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,
getReplaySource: () => enabled ? { type: 'ptz', id: PTZ_CAMERA_ID, label: cameraConfig.name || 'PTZ Camera' } : null,
getReplayWorkerSource: () => enabled ? {
id: PTZ_CAMERA_ID,
sourceType: 'ptz',
kind: 'video',
label: cameraConfig.name || 'PTZ Camera',
inputUrl: `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`,
} : null,
};
@@ -3,6 +3,7 @@
// Scope: Handles user-visible replay source catalogs and default source selection rules.
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const ptzCameraService = require('../ptzCameraService');
function getReplaySources(socket = null) {
const roster = socket ? roverManager.getRosterForSocket(socket) : roverManager.getRoster();
@@ -21,7 +22,10 @@ function getReplaySources(socket = null) {
label: camera.name || camera.id,
}));
return [...roverSources, ...roomSources];
const ptzSource = ptzCameraService.getReplaySource();
const ptzSources = ptzSource ? [ptzSource] : [];
return [...roverSources, ...roomSources, ...ptzSources];
}
function normalizeSource(entry) {
@@ -67,11 +71,14 @@ function getDefaultWebSources(assignment = {}, socket = null) {
}
function getDefaultDiscordSources() {
return getRoomCameras().map((camera) => ({
const sources = getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
}));
const ptzSource = ptzCameraService.getReplaySource();
if (ptzSource) sources.push(ptzSource);
return sources;
}
module.exports = {
@@ -8,6 +8,7 @@ const logger = require('../../globals/logger').child('replayEngineV2');
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
const { workers, segmentIndex } = require('./state');
const { sourceKey, sourceDirForKey } = require('./sources');
const ptzCameraService = require('../ptzCameraService');
async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true });
@@ -123,6 +124,8 @@ function createSegmentStore({ getActiveSegmentRoot }) {
for (const camera of getRoomCameras()) {
replaySources.push({ type: 'room', id: String(camera.id), label: camera.name || camera.id });
}
const ptzSource = ptzCameraService.getReplaySource();
if (ptzSource) replaySources.push(ptzSource);
for (const source of replaySources) {
const key = sourceKey({ sourceType: source.type, kind: 'video', id: String(source.id) });
@@ -4,6 +4,7 @@
const path = require('path');
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const ptzCameraService = require('../ptzCameraService');
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
function sourceKey(source) {
@@ -46,6 +47,8 @@ function listDesiredSources() {
if (!streamUrl) continue;
sources.push({ id: String(camera.id), sourceType: 'room', kind: 'video', label: camera.name || camera.id, inputUrl: streamUrl });
}
const ptzSource = ptzCameraService.getReplayWorkerSource();
if (ptzSource) sources.push(ptzSource);
return sources;
}
@@ -89,6 +89,7 @@ const {
canDrive,
getRoversForSocket,
getPrimaryRoverForSocket,
canLeaveCurrentRover,
canSwitchRover,
} = roverLifecycle;
@@ -277,6 +278,7 @@ module.exports = {
managerEvents,
getRoversForSocket,
getPrimaryRoverForSocket,
canLeaveCurrentRover,
canSeeRover,
canRequestControl,
applyPrivateDriveSafety,
@@ -137,6 +137,22 @@ function createRoverLifecycle(deps) {
return false;
}
function canLeaveCurrentRover(socket, options = {}) {
const currentId = getPrimaryRoverForSocket(socket.id);
/*
Leaving a rover is only risky when this socket is the last person attached
to an undocked rover. The same rule is used for rover-to-rover switching
and PTZ camera claiming so there is exactly one definition of "do not
abandon a rover in the room".
*/
if (!currentId || currentId === options.targetRoverId) return { ok: true, currentId };
const currentRecord = rovers.get(currentId);
if (!currentRecord) return { ok: true, currentId };
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
}
function canSwitchRover(socket, targetRoverId, options = {}) {
const target = rovers.get(targetRoverId);
if (!target) return { ok: false, message: 'Unknown rover' };
@@ -145,13 +161,7 @@ function createRoverLifecycle(deps) {
allowClosedPrivateGrantInLockdown: Boolean(options.allowClosedPrivateGrantInLockdown),
});
if (denied) return { ok: false, message: denied };
const currentId = getPrimaryRoverForSocket(socket.id);
if (!currentId || currentId === targetRoverId) return { ok: true, currentId };
const currentRecord = rovers.get(currentId);
if (!currentRecord) return { ok: true, currentId };
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
return canLeaveCurrentRover(socket, { targetRoverId });
}
function canReplayRoverId(roverId, isPrivateRecord, isPrivateOpen) {
@@ -169,6 +179,7 @@ function createRoverLifecycle(deps) {
canDrive,
getRoversForSocket,
getPrimaryRoverForSocket,
canLeaveCurrentRover,
canSwitchRover,
canReplayRoverId,
};
@@ -10,6 +10,7 @@ const { managerEvents } = roverManager;
const assignmentService = require('../assignmentService');
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const { getPublicState: getPtzCameraState, ptzCameraEvents } = require('../ptzCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService');
@@ -107,6 +108,7 @@ function buildSession(socket) {
activeDrivers,
turnQueues,
roomCameras: getRoomCameras(),
ptzCamera: getPtzCameraState(socket),
homeAssistant: getHomeAssistantState(),
neato: getNeatoState(),
lift: getLiftState(),
@@ -281,6 +283,11 @@ roomCameraEvents.on('update', () => {
syncAll();
});
ptzCameraEvents.on('change', () => {
logger.info('PTZ camera state change; syncing all clients');
syncAll();
});
homeAssistantEvents.on('update', () => {
logger.info('Home Assistant state change; syncing all clients');
syncAll();
@@ -10,6 +10,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const { isVerified } = require('../verificationService');
const turnService = require('../turnService');
const roverManager = require('../roverManager');
const ptzCameraService = require('../ptzCameraService');
const { getRequestIp, getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const { logAdminEvent } = require('../adminLogService');
@@ -26,6 +27,7 @@ const { canAccessStream } = createVideoAuthPolicy({
isVerified,
turnService,
roverManager,
ptzCameraService,
getSocketIp,
isLocalNetwork,
});
@@ -11,6 +11,7 @@ function createVideoAuthPolicy(deps) {
isVerified,
turnService,
roverManager,
ptzCameraService,
getSocketIp,
isLocalNetwork,
} = deps;
@@ -40,6 +41,10 @@ function createVideoAuthPolicy(deps) {
}
}
if (streamInfo.type === 'ptz') {
return ptzCameraService.canRequestLiveVideo(socket);
}
if (sourceType === 'roverMic' && action === 'publish') {
const roverId = streamInfo.baseId || streamInfo.id;
if (!isVerified(socket)) {
@@ -48,6 +48,10 @@ function extractStreamInfo(path) {
return { type: 'room', id: remaining[1] || '' };
}
if (remaining.length === 2 && remaining[0] === 'ptz') {
return { type: 'ptz', id: remaining[1] || '' };
}
return null;
}
@@ -7,6 +7,7 @@ const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const videoSessions = require('../videoSessions');
const roverManager = require('../roverManager');
const ptzCameraService = require('../ptzCameraService');
const { loadConfig } = require('../../helpers/configLoader');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
@@ -34,6 +35,8 @@ function buildWhepUrlForSource(source) {
const segments = [];
if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id));
} else if (source.type === 'ptz') {
segments.push('ptz', encodeURIComponent(source.id));
} else {
segments.push(encodeURIComponent(source.id));
}
@@ -81,6 +84,9 @@ function normalizeRequest(payload = {}) {
if (payload.roomCameraId) {
return { type: 'room', id: String(payload.roomCameraId) };
}
if (payload.ptzCameraId || payload.type === 'ptz') {
return { type: 'ptz', id: String(payload.ptzCameraId || payload.id || ptzCameraService.PTZ_CAMERA_ID) };
}
return null;
}
@@ -109,6 +115,13 @@ io.on('connection', (socket) => {
}
} else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed');
} else if (target.type === 'ptz') {
if (target.id !== ptzCameraService.PTZ_CAMERA_ID) {
throw new Error('Unknown PTZ camera');
}
if (!ptzCameraService.canRequestLiveVideo(socket)) {
throw new Error('Not authorized for PTZ video');
}
} else {
throw new Error('Unsupported video source');
}
+2
View File
@@ -11,6 +11,7 @@ import VipVerificationCard from '../vip/VipVerificationCard.jsx';
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
import VipPtzCameraCard from '../vip/VipPtzCameraCard.jsx';
export default function VipPanel() {
const session = useSessionSelector((state) => state.session);
@@ -69,6 +70,7 @@ export default function VipPanel() {
<div className="lg:col-span-2">
{isVerified ? (
<div className="space-y-2">
<VipPtzCameraCard onMessage={setMessage} fullWidth />
<VipMidiBeeperCard />
<VipAudioUploadCard
ownRoverId={ownRoverId}
@@ -0,0 +1,369 @@
// Vip PTZ Camera Card
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaArrowDown, FaArrowLeft, FaArrowRight, FaArrowUp, FaSearchMinus, FaSearchPlus, FaStop } from 'react-icons/fa';
import CardFrame from '../CardFrame/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { usePtzCameraSnapshot } from '../../hooks/usePtzCameraSnapshot.js';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { innerFlowClass } from './constants.js';
const PTZ_CAMERA_ID = 'ptz-camera';
function formatRemaining(deadline) {
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
if (!remaining) return '--';
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function PtzSnapshotPreview({ feed, label = 'PTZ Camera' }) {
return (
<div className="relative w-full overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
{feed?.objectUrl ? (
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
)}
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-1 py-0.5 text-xs font-semibold text-white">
{label}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 m-1 rounded bg-black/70 px-1 py-0.5 text-[0.7rem] text-slate-100">
{feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}
</div>
</div>
);
}
function ControlButton({ title, children, onHold, className = '' }) {
const activeRef = useRef(false);
const start = useCallback(
(event) => {
event.preventDefault();
activeRef.current = true;
onHold?.('start');
},
[onHold],
);
const stop = useCallback(
(event) => {
event?.preventDefault?.();
if (!activeRef.current) return;
activeRef.current = false;
onHold?.('stop');
},
[onHold],
);
return (
<button
type="button"
title={title}
aria-label={title}
onPointerDown={start}
onPointerUp={stop}
onPointerCancel={stop}
onPointerLeave={stop}
className={`button-dark flex h-10 min-w-10 items-center justify-center text-sm ${className}`}
>
{children}
</button>
);
}
function PtzLiveVideo({ enabled }) {
const videoRef = useRef(null);
const playerRef = useRef(null);
const [status, setStatus] = useState('idle');
const sources = useVideoRequests(
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
{ enabled },
);
const source = sources[PTZ_CAMERA_ID] || null;
useEffect(() => {
if (!enabled || !source?.url || !videoRef.current) return undefined;
/*
WhepPlayer already owns PeerConnection setup, low-latency hints, auth
headers, and cleanup for rover video. Reusing it keeps PTZ video on the
same MediaMTX browser path as the rest of the app.
*/
const player = new WhepPlayer({
url: source.url,
token: source.token,
video: videoRef.current,
onStatus: setStatus,
receiveAudio: false,
});
playerRef.current = player;
player.start().catch((err) => setStatus(err.message || 'error'));
return () => {
player.stop();
playerRef.current = null;
};
}, [enabled, source?.token, source?.url]);
return (
<div className="relative h-full w-full bg-black">
<video ref={videoRef} className="h-full w-full object-contain" muted playsInline autoPlay />
<div className="pointer-events-none absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-slate-100">
{source?.error || status}
</div>
</div>
);
}
function PtzController({ open, onClose }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isOperator = Boolean(ptz?.isOperator);
const { ptzMove, ptzStop, ptzSpotlight, ptzIr, ptzRelease } = useSessionActions();
const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator });
const [busy, setBusy] = useState('');
const sendMove = useCallback(
(payload) => {
ptzMove(payload).catch(() => {});
},
[ptzMove],
);
const stopMotion = useCallback(() => {
ptzStop().catch(() => {});
}, [ptzStop]);
const holdMove = useCallback(
(payload) => (phase) => {
if (phase === 'start') sendMove(payload);
else stopMotion();
},
[sendMove, stopMotion],
);
useEffect(() => {
if (!open || !isOperator) return undefined;
/*
Keyboard control is intentionally active only while the fullscreen PTZ
controller is open. Releasing, blurring, or losing operator status sends a
stop command so continuous ONVIF movement cannot be left running.
*/
const pressed = new Set();
const recompute = () => {
let pan = 0;
let tilt = 0;
let zoom = 0;
if (pressed.has('ArrowLeft') || pressed.has('KeyA')) pan -= 0.55;
if (pressed.has('ArrowRight') || pressed.has('KeyD')) pan += 0.55;
if (pressed.has('ArrowUp') || pressed.has('KeyW')) tilt += 0.55;
if (pressed.has('ArrowDown') || pressed.has('KeyS')) tilt -= 0.55;
if (pressed.has('KeyQ')) zoom += 0.55;
if (pressed.has('KeyE')) zoom -= 0.55;
if (pan || tilt || zoom) sendMove({ pan, tilt, zoom });
else stopMotion();
};
const onKeyDown = (event) => {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyD', 'KeyW', 'KeyS', 'KeyQ', 'KeyE', 'Space'].includes(event.code)) return;
event.preventDefault();
if (event.code === 'Space') {
pressed.clear();
stopMotion();
return;
}
if (!pressed.has(event.code)) {
pressed.add(event.code);
recompute();
}
};
const onKeyUp = (event) => {
if (!pressed.delete(event.code)) return;
event.preventDefault();
recompute();
};
const onBlur = () => {
pressed.clear();
stopMotion();
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
stopMotion();
};
}, [isOperator, open, sendMove, stopMotion]);
if (!open) return null;
const toggleSpotlight = async () => {
setBusy('spotlight');
try {
await ptzSpotlight({ state: ptz?.light?.state ? 0 : 1 });
} finally {
setBusy('');
}
};
const toggleIr = async () => {
setBusy('ir');
try {
await ptzIr({ state: ptz?.ir?.state === 'Off' ? 'Auto' : 'Off' });
} finally {
setBusy('');
}
};
return (
<div className="fixed inset-0 z-[110] flex bg-black text-slate-100">
<main className="relative flex min-w-0 flex-1 items-center justify-center bg-black">
{isOperator ? <PtzLiveVideo enabled /> : <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />}
</main>
<aside className="flex w-72 shrink-0 flex-col gap-1 border-l border-slate-700 bg-neutral-950 p-2 text-sm">
<div className="flex items-center justify-between gap-1">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">{ptz?.name || 'PTZ Camera'}</p>
<p className="truncate text-xs text-slate-400">{isOperator ? 'operator' : 'snapshot view'}</p>
</div>
<button type="button" className="button-dark text-xs" onClick={onClose}>Close</button>
</div>
<div className="surface-muted space-y-0.5 p-1 text-xs">
<div className="flex justify-between gap-1"><span>Operator</span><span className="truncate text-slate-200">{ptz?.operatorLabel || 'none'}</span></div>
<div className="flex justify-between gap-1"><span>Remaining</span><span>{formatRemaining(ptz?.deadline)}</span></div>
<div className="flex justify-between gap-1"><span>Spotlight</span><span>{ptz?.light?.state ? 'On' : 'Off'}</span></div>
<div className="flex justify-between gap-1"><span>IR</span><span>{ptz?.ir?.state || '--'}</span></div>
</div>
{isOperator ? (
<>
<div className="grid grid-cols-3 gap-1">
<div />
<ControlButton title="Tilt up" onHold={holdMove({ tilt: 0.55 })}><FaArrowUp /></ControlButton>
<div />
<ControlButton title="Pan left" onHold={holdMove({ pan: -0.55 })}><FaArrowLeft /></ControlButton>
<ControlButton title="Stop" onHold={(phase) => phase === 'start' && stopMotion()}><FaStop /></ControlButton>
<ControlButton title="Pan right" onHold={holdMove({ pan: 0.55 })}><FaArrowRight /></ControlButton>
<div />
<ControlButton title="Tilt down" onHold={holdMove({ tilt: -0.55 })}><FaArrowDown /></ControlButton>
<div />
</div>
<div className="grid grid-cols-2 gap-1">
<ControlButton title="Zoom in" onHold={holdMove({ zoom: 0.55 })}><FaSearchPlus /></ControlButton>
<ControlButton title="Zoom out" onHold={holdMove({ zoom: -0.55 })}><FaSearchMinus /></ControlButton>
</div>
<div className="grid grid-cols-2 gap-1">
<button type="button" className="button-dark text-xs" disabled={busy === 'spotlight'} onClick={toggleSpotlight}>
Spotlight {ptz?.light?.state ? 'off' : 'on'}
</button>
<button type="button" className="button-dark text-xs" disabled={busy === 'ir'} onClick={toggleIr}>
IR {ptz?.ir?.state === 'Off' ? 'auto' : 'off'}
</button>
</div>
<button type="button" className="button-dark mt-auto text-xs" onClick={() => ptzRelease().finally(onClose)}>
Release camera
</button>
</>
) : (
<p className="surface-muted p-2 text-xs text-slate-400">Live PTZ controls unlock when your camera turn is active.</p>
)}
</aside>
</div>
);
}
export default function VipPtzCameraCard({ onMessage, fullWidth = false }) {
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const { ptzClaim, ptzRelease } = useSessionActions();
const snapshot = usePtzCameraSnapshot({ enabled: Boolean(featureEnabled) });
const [controllerOpen, setControllerOpen] = useState(false);
const [pending, setPending] = useState(false);
const wrapClass = fullWidth ? 'w-full' : 'mx-auto w-full max-w-xl';
const queueText = useMemo(() => {
if (ptz?.isOperator) return 'Your turn';
if (ptz?.queuedPosition) return `Queue position ${ptz.queuedPosition}`;
if (ptz?.operatorLabel) return `${ptz.operatorLabel} operating`;
return 'Available';
}, [ptz?.isOperator, ptz?.operatorLabel, ptz?.queuedPosition]);
if (!featureEnabled) return null;
const handleClaim = async () => {
setPending(true);
onMessage?.('');
try {
const response = await ptzClaim();
if (response?.state?.isOperator) onMessage?.('PTZ camera turn active.');
else if (response?.state?.queuedPosition) onMessage?.(`Joined PTZ queue at position ${response.state.queuedPosition}.`);
} catch (err) {
onMessage?.(err.message || 'PTZ request failed.');
} finally {
setPending(false);
}
};
const handleRelease = async () => {
setPending(true);
try {
await ptzRelease();
onMessage?.('Left PTZ camera.');
} catch (err) {
onMessage?.(err.message || 'Failed to leave PTZ camera.');
} finally {
setPending(false);
}
};
return (
<>
<CardFrame title="PTZ camera" className={wrapClass} bodyClassName="text-sm text-slate-300">
<div className={innerFlowClass}>
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
<div className="grid w-full grid-cols-2 gap-1 text-left text-xs">
<div className="surface-muted p-1">
<p className="text-slate-500">State</p>
<p className="truncate text-slate-100">{queueText}</p>
</div>
<div className="surface-muted p-1">
<p className="text-slate-500">Remaining</p>
<p className="text-slate-100">{formatRemaining(ptz?.deadline)}</p>
</div>
</div>
{ptz?.blocked?.message ? (
<p className="w-full rounded border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
{ptz.blocked.message}
</p>
) : null}
<div className="flex w-full flex-wrap justify-center gap-1">
{ptz?.isOperator ? (
<>
<button type="button" className="button-dark text-xs" onClick={() => setControllerOpen(true)}>
Open controller
</button>
<button type="button" className="button-dark text-xs" disabled={pending} onClick={handleRelease}>
Release
</button>
</>
) : (
<button
type="button"
className="button-dark text-xs"
disabled={pending || !isVerified}
onClick={ptz?.queuedPosition ? handleRelease : handleClaim}
>
{ptz?.queuedPosition ? 'Leave queue' : pending ? 'Requesting...' : 'Claim camera'}
</button>
)}
</div>
</div>
</CardFrame>
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} />
</>
);
}
+6
View File
@@ -420,6 +420,12 @@ export function SessionProvider({ children }) {
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
setPrivateSafety: (roverId, safety = {}) =>
emitWithAck('session:privateSafety:set', { roverId, safety }),
ptzClaim: () => emitWithAck('ptzCamera:claim'),
ptzRelease: () => emitWithAck('ptzCamera:release'),
ptzMove: (payload = {}) => emitWithAck('ptzCamera:move', payload),
ptzStop: () => emitWithAck('ptzCamera:stop'),
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }),
overseerControl: (action, controls = {}) =>
+74
View File
@@ -0,0 +1,74 @@
// Hook: usePtzCameraSnapshot
// Purpose: Subscribes to the server-generated PTZ camera snapshot feed.
// Scope: Mirrors the rover snapshot object-URL lifecycle while using PTZ-specific socket events and authorization.
import { useEffect, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
export function usePtzCameraSnapshot(options = {}) {
const socket = useSocket();
const { enabled = true, version = null } = options;
const [feed, setFeed] = useState(null);
const objectUrlRef = useRef(null);
const [connectionNonce, setConnectionNonce] = useState(0);
useEffect(() => {
if (!socket) return undefined;
const handleConnect = () => setConnectionNonce((prev) => prev + 1);
socket.on('connect', handleConnect);
return () => socket.off('connect', handleConnect);
}, [socket]);
useEffect(() => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
setFeed(null);
}, [version]);
useEffect(() => {
if (!enabled || !socket) return undefined;
let cancelled = false;
const handleFrame = (meta = {}, buffer) => {
if (cancelled || !buffer) return;
const url = URL.createObjectURL(new Blob([buffer], { type: 'image/jpeg' }));
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = url;
setFeed({
status: 'playing',
ts: meta.ts || Date.now(),
error: null,
objectUrl: url,
});
};
const handleStatus = (meta = {}) => {
if (cancelled) return;
setFeed((prev) => ({
...(prev || {}),
status: meta.error ? 'error' : prev?.status || 'connecting',
error: meta.error || null,
ts: meta.ts || prev?.ts || null,
objectUrl: prev?.objectUrl || null,
}));
};
socket.on('ptzCamera:snapshotFrame', handleFrame);
socket.on('ptzCamera:snapshotStatus', handleStatus);
socket.emit('ptzCamera:snapshotSubscribe', {}, () => {});
return () => {
cancelled = true;
socket.emit('ptzCamera:snapshotUnsubscribe');
socket.off('ptzCamera:snapshotFrame', handleFrame);
socket.off('ptzCamera:snapshotStatus', handleStatus);
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, [socket, enabled, version, connectionNonce]);
return feed;
}
+6 -1
View File
@@ -88,7 +88,12 @@ export function useVideoRequests(sourceList = [], options = {}) {
let cancelled = false;
function requestEntry(entry) {
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
const payload =
entry.type === 'room'
? { roomCameraId: entry.id }
: entry.type === 'ptz'
? { type: 'ptz', id: entry.id }
: { roverId: entry.id };
socket.emit('video:request', payload, (resp = {}) => {
if (cancelled) return;
if (resp?.error) {