mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
ptzwawa
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user