room camera and rover snapshots

This commit is contained in:
legop3
2026-04-29 13:58:04 -04:00
parent 40e55240e4
commit 0003338ecd
16 changed files with 757 additions and 799 deletions
@@ -0,0 +1,57 @@
// Room Camera Catalog
// Purpose: Loads and normalizes configured room camera entries and emits update events when roster data changes.
// Scope: Owns camera identity/url normalization and read-only accessors for room camera metadata.
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('roomCameraService');
const { loadConfig } = require('../../helpers/configLoader');
const events = new EventEmitter();
const config = loadConfig();
const cameraMap = new Map();
function normalizeCamera(camera) {
if (!camera) return null;
const id = camera.id || camera.name;
if (!id) {
logger.warn('Room camera missing id', camera);
return null;
}
if (!camera.url && !camera.streamUrl && !camera.mjpegUrl) {
logger.warn('Room camera missing url/streamUrl', { id, camera });
return null;
}
return {
id: String(id),
name: camera.name || camera.id || String(id),
description: camera.description || null,
url: camera.url || null,
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
};
}
function getRoomCameras() {
return Array.from(cameraMap.values());
}
function getRoomCamera(id) {
if (!id) return null;
return cameraMap.get(String(id)) || null;
}
function loadFromConfig() {
cameraMap.clear();
const list = Array.isArray(config.roomCameras) ? config.roomCameras : [];
list.forEach((camera) => {
const normalized = normalizeCamera(camera);
if (normalized) cameraMap.set(normalized.id, normalized);
});
logger.info('Loaded room cameras', { count: cameraMap.size });
events.emit('update', getRoomCameras());
}
module.exports = {
loadFromConfig,
getRoomCameras,
getRoomCamera,
roomCameraEvents: events,
};
+27 -52
View File
@@ -1,61 +1,36 @@
// room Camera Service
// Purpose: Defines the room Camera Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('roomCameraService');
const { loadConfig } = require('../../helpers/configLoader');
// Room Camera Service
// Purpose: Composes room camera catalog, snapshot streaming, socket delivery, and replay helpers in one service folder.
// Scope: Exposes the existing room-camera public API while preserving side-effect startup behavior.
const { loadFromConfig, getRoomCameras, getRoomCamera, roomCameraEvents } = require('./catalog');
const { createSnapshotEngine } = require('./snapshotEngine');
const { registerRoomCameraSocketGateway } = require('./socketGateway');
const replay = require('../replayEngineV2/roomCameraReplayBuilder');
const events = new EventEmitter();
const config = loadConfig();
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
snapshotEngine.startAll();
const cameraMap = new Map();
function normalizeCamera(camera) {
if (!camera) return null;
const id = camera.id || camera.name;
if (!id) {
logger.warn('Room camera missing id', camera);
return null;
}
if (!camera.url && !camera.streamUrl && !camera.mjpegUrl) {
logger.warn('Room camera missing url/streamUrl', { id, camera });
return null;
}
return {
id: String(id),
name: camera.name || camera.id || String(id),
description: camera.description || null,
url: camera.url || null,
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
};
}
function loadFromConfig() {
cameraMap.clear();
const list = Array.isArray(config.roomCameras) ? config.roomCameras : [];
list.forEach((camera) => {
const normalized = normalizeCamera(camera);
if (normalized) {
cameraMap.set(normalized.id, normalized);
}
});
logger.info('Loaded room cameras', { count: cameraMap.size });
events.emit('update', getRoomCameras());
}
function getRoomCameras() {
return Array.from(cameraMap.values());
}
function getRoomCamera(id) {
if (!id) return null;
return cameraMap.get(String(id)) || null;
}
registerRoomCameraSocketGateway({
getRoomCamera,
getRoomCameras,
getRoomCameraState: snapshotEngine.getRoomCameraState,
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
});
loadFromConfig();
function buildRoomCameraReplayVideo(options = {}) {
return replay.buildRoomCameraReplayVideo(options, { getRoomCamera, getRoomCameras });
}
module.exports = {
getRoomCameras,
getRoomCamera,
roomCameraEvents: events,
roomCameraEvents,
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
getRoomCameraState: snapshotEngine.getRoomCameraState,
recordRoomCameraFrame: replay.recordRoomCameraFrame,
clearRoomCameraReplayFrames: replay.clearRoomCameraReplayFrames,
getRoomCameraReplayMetadata: replay.getRoomCameraReplayMetadata,
buildRoomCameraReplayVideo,
roomCameraReplayEvents: replay.roomCameraReplayEvents,
};
@@ -0,0 +1,221 @@
// Room Camera Snapshot Engine
// Purpose: Maintains per-camera snapshot/stream frame state and handles polling/reconnect loops for camera feeds.
// Scope: Owns frame acquisition from MJPEG streams or snapshot URLs and emits frame/status events.
const EventEmitter = require('events');
const http = require('http');
const https = require('https');
const logger = require('../../globals/logger').child('roomCameraSnapshot');
const POLL_INTERVAL_MS = 67;
const FETCH_TIMEOUT_MS = 2000;
const STREAM_RETRY_MS = 1500;
const cameraState = new Map();
const streamState = new Map();
const events = new EventEmitter();
let pollTimer = null;
const JPEG_START = Buffer.from([0xff, 0xd8]);
const JPEG_END = Buffer.from([0xff, 0xd9]);
function markState(id, updates = {}) {
const prev = cameraState.get(id) || {};
const next = { ...prev, ...updates };
cameraState.set(id, next);
return next;
}
function isMjpegUrl(rawUrl) {
if (!rawUrl) return false;
const lower = String(rawUrl).toLowerCase();
return lower.endsWith('.mjpg') || lower.endsWith('.mjpeg') || lower.includes('mjpeg');
}
function getStreamUrl(camera) {
if (camera.streamUrl) return camera.streamUrl;
if (isMjpegUrl(camera.url)) return camera.url;
return null;
}
function stopStream(id) {
const entry = streamState.get(id);
if (!entry) return;
if (entry.req) entry.req.destroy();
if (entry.reconnectTimer) clearTimeout(entry.reconnectTimer);
streamState.delete(id);
}
function scheduleStreamReconnect(camera, startStream) {
const id = camera.id;
const entry = streamState.get(id) || {};
if (entry.reconnectTimer) return;
entry.reconnectTimer = setTimeout(() => {
const current = streamState.get(id) || {};
current.reconnectTimer = null;
streamState.set(id, current);
startStream(camera);
}, STREAM_RETRY_MS);
streamState.set(id, entry);
}
function clearStreamRequest(id, req) {
const entry = streamState.get(id);
if (!entry || entry.req !== req) return;
entry.req = null;
streamState.set(id, entry);
}
function handleStreamError(camera, err) {
const state = cameraState.get(camera.id) || {};
const failures = (state.failures || 0) + 1;
markState(camera.id, { error: err.message || 'stream error', failures });
events.emit('status', { id: camera.id, error: err.message || 'stream error' });
logger.warn('Stream failed', { id: camera.id, err: err.message });
}
function createSnapshotEngine({ getRoomCameras, roomCameraEvents }) {
function startStream(camera) {
const streamUrl = getStreamUrl(camera);
if (!streamUrl || streamState.get(camera.id)?.req) return;
const url = new URL(streamUrl);
const client = url.protocol === 'https:' ? https : http;
const req = client.get(
{
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers: { Accept: 'multipart/x-mixed-replace' },
},
(res) => {
if (res.statusCode !== 200) {
res.resume();
clearStreamRequest(camera.id, req);
handleStreamError(camera, new Error(`HTTP ${res.statusCode}`));
scheduleStreamReconnect(camera, startStream);
return;
}
let buffer = Buffer.alloc(0);
res.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
let start = buffer.indexOf(JPEG_START);
if (start === -1) {
if (buffer.length > 2 * 1024 * 1024) buffer = buffer.slice(-1024 * 1024);
break;
}
if (start > 0) {
buffer = buffer.slice(start);
start = 0;
}
const end = buffer.indexOf(JPEG_END, 2);
if (end === -1) break;
const frame = buffer.slice(0, end + 2);
buffer = buffer.slice(end + 2);
const ts = Date.now();
markState(camera.id, { frame, ts, error: null, failures: 0 });
events.emit('frame', { id: camera.id, buffer: frame, ts });
}
});
res.on('end', () => {
clearStreamRequest(camera.id, req);
scheduleStreamReconnect(camera, startStream);
});
res.on('aborted', () => {
clearStreamRequest(camera.id, req);
handleStreamError(camera, new Error('stream aborted'));
scheduleStreamReconnect(camera, startStream);
});
res.on('error', (err) => {
clearStreamRequest(camera.id, req);
handleStreamError(camera, err);
scheduleStreamReconnect(camera, startStream);
});
},
);
req.on('error', (err) => {
clearStreamRequest(camera.id, req);
handleStreamError(camera, err);
scheduleStreamReconnect(camera, startStream);
});
streamState.set(camera.id, { req, reconnectTimer: null });
}
async function fetchSnapshot(camera) {
const { id, url } = camera;
const state = cameraState.get(id);
if (!url || state?.fetching) return;
markState(id, { fetching: true });
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: abortController.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const ts = Date.now();
markState(id, { frame: buffer, ts, error: null, failures: 0 });
events.emit('frame', { id, buffer, ts });
} catch (err) {
const failures = (state?.failures || 0) + 1;
markState(id, { error: err.message, failures });
events.emit('status', { id, error: err.message });
logger.warn('Snapshot fetch failed', { id, err: err.message });
} finally {
clearTimeout(timeout);
markState(id, { fetching: false });
}
}
function stopAll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
Array.from(streamState.keys()).forEach((id) => stopStream(id));
cameraState.clear();
}
function startAll() {
stopAll();
const cameras = getRoomCameras();
const snapshotCameras = cameras.filter((camera) => !getStreamUrl(camera) && camera.url);
cameras.forEach((camera) => startStream(camera));
if (snapshotCameras.length) {
pollTimer = setInterval(() => {
snapshotCameras.forEach((camera) => fetchSnapshot(camera));
}, POLL_INTERVAL_MS);
snapshotCameras.forEach((camera) => fetchSnapshot(camera));
}
logger.info('Started room camera feeds', {
total: cameras.length,
streaming: cameras.length - snapshotCameras.length,
snapshots: snapshotCameras.length,
});
}
function getRoomCameraState(id) {
const state = cameraState.get(id);
if (!state) return null;
return {
frame: state.frame || null,
ts: state.ts || null,
error: state.error || null,
};
}
roomCameraEvents.on('update', () => {
logger.info('Room cameras changed; restarting snapshot pollers');
startAll();
});
return {
startAll,
stopAll,
roomCameraStreamEvents: events,
getRoomCameraState,
};
}
module.exports = {
createSnapshotEngine,
};
@@ -0,0 +1,145 @@
// Room Camera Socket Gateway
// Purpose: Manages room-camera subscription sockets and forwards frame/status events to authorized viewers.
// Scope: Owns socket-level auth checks, subscription state, throttling, and event fan-out.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('roomCameraSocket');
const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 1000;
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 registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoomCameraState, roomCameraStreamEvents }) {
const cameraSubscribers = new Map();
const socketSubscriptions = new Map();
const subscribeBuckets = new Map();
const lastSentBySocket = new Map();
function addSubscription(socket, cameraId) {
if (!cameraSubscribers.has(cameraId)) cameraSubscribers.set(cameraId, new Set());
cameraSubscribers.get(cameraId).add(socket.id);
if (!socketSubscriptions.has(socket.id)) socketSubscriptions.set(socket.id, new Set());
socketSubscriptions.get(socket.id).add(cameraId);
}
function removeSubscription(socketId, cameraId) {
const bucket = cameraSubscribers.get(cameraId);
if (bucket) {
bucket.delete(socketId);
if (bucket.size === 0) cameraSubscribers.delete(cameraId);
}
const socketBucket = socketSubscriptions.get(socketId);
if (socketBucket) {
socketBucket.delete(cameraId);
if (socketBucket.size === 0) socketSubscriptions.delete(socketId);
}
}
function removeAllSubscriptions(socketId) {
const bucket = socketSubscriptions.get(socketId);
if (!bucket) return;
bucket.forEach((cameraId) => removeSubscription(socketId, cameraId));
}
function allowSubscribe(socketId) {
const now = Date.now();
let bucket = subscribeBuckets.get(socketId);
if (!bucket || now - bucket.start >= SUBSCRIBE_WINDOW_MS) bucket = { start: now, count: 0 };
bucket.count += 1;
subscribeBuckets.set(socketId, bucket);
return bucket.count <= SUBSCRIBE_LIMIT;
}
function sendFrame(socket, cameraId, payload, buffer) {
socket.emit('roomCamera:frame', { id: cameraId, ...payload }, buffer);
}
function sendStatus(socket, cameraId, status) {
socket.emit('roomCamera:status', { id: cameraId, ...status });
}
roomCameraStreamEvents.on('frame', ({ id, buffer, ts }) => {
const bucket = cameraSubscribers.get(id);
if (!bucket || !buffer) return;
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
let lastMap = lastSentBySocket.get(socketId);
if (!lastMap) {
lastMap = new Map();
lastSentBySocket.set(socketId, lastMap);
}
const lastSent = lastMap.get(id) || 0;
const now = ts || Date.now();
if (now - lastSent < STREAM_INTERVAL_MS) return;
lastMap.set(id, now);
sendFrame(socket, id, { ts }, buffer);
});
});
roomCameraStreamEvents.on('status', ({ id, error }) => {
const bucket = cameraSubscribers.get(id);
if (!bucket) return;
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
sendStatus(socket, id, { error: error || null });
});
});
io.on('connection', (socket) => {
socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: getRoomCameras().map((cam) => cam.id);
const uniqueIds = Array.from(new Set(list));
try {
if (!allowSubscribe(socket.id)) return cb({ error: 'Rate limited' });
if (!passesMode(socket)) throw new Error('Not authorized for room camera');
const validIds = uniqueIds.filter((id) => !!getRoomCamera(id));
validIds.forEach((cameraId) => addSubscription(socket, cameraId));
validIds.forEach((cameraId) => {
const state = getRoomCameraState(cameraId);
if (state?.frame) sendFrame(socket, cameraId, { ts: state.ts }, state.frame);
sendStatus(socket, cameraId, { ts: state?.ts || null, error: state?.error || null });
});
cb({ ok: true, subscribed: validIds });
} catch (err) {
logger.warn('Room camera subscribe failed', { socketId: socket.id, err: err.message });
cb({ error: err.message });
}
});
socket.on('roomCamera:unsubscribe', (payload = {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: [];
list.forEach((cameraId) => removeSubscription(socket.id, cameraId));
});
socket.on('disconnect', () => {
removeAllSubscriptions(socket.id);
subscribeBuckets.delete(socket.id);
lastSentBySocket.delete(socket.id);
});
});
}
module.exports = {
registerRoomCameraSocketGateway,
};