This commit is contained in:
legop3
2025-11-20 15:21:13 -05:00
parent bfdfa33b62
commit ea59a196bb
10 changed files with 296 additions and 56 deletions
+3 -2
View File
@@ -5,9 +5,10 @@
- add support for a path of room cameras on mediamtx - add support for a path of room cameras on mediamtx
- /room/<camera_name> - /room/<camera_name>
- CANNOT interfere with rover cameras (/<rover_name>) - CANNOT interfere with rover cameras (/<rover_name>)
- needs to use the same auth system as the rover cameras - needs to use the same auth system as the rover camera
- room cameras will be streamed to the server over SRT
## what needs to happen in the web UI ## what needs to happen in the web UI
- users should be able to see all room cameras, even if not assigned to a rover - users should be able to see all room cameras, even if not assigned to a rover
- automatically add a room camera player for each room camera - automatically add a room camera player for each room camera
- make a component that shows all room cameras
+7
View File
@@ -12,3 +12,10 @@ media:
# http://<base>/<roverId>/whep # http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video # Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video" whepBaseUrl: "http://192.168.0.86:8889/video"
roomCameras:
- id: "lobby"
name: "Lobby Camera"
description: "Wide shot of the staging area."
- id: "workshop"
name: "Workshop Bench"
description: "Shows the workbench and charging docks."
+52
View File
@@ -0,0 +1,52 @@
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;
}
return {
id: String(id),
name: camera.name || camera.id || String(id),
description: camera.description || 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;
}
loadFromConfig();
module.exports = {
getRoomCameras,
getRoomCamera,
roomCameraEvents: events,
};
+7
View File
@@ -6,6 +6,7 @@ const roverManager = require('./roverManager');
const { managerEvents } = roverManager; const { managerEvents } = roverManager;
const assignmentService = require('./assignmentService'); const assignmentService = require('./assignmentService');
const { getActiveDrivers, turnEvents } = require('./turnService'); const { getActiveDrivers, turnEvents } = require('./turnService');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
function buildSession(socket) { function buildSession(socket) {
return { return {
@@ -15,6 +16,7 @@ function buildSession(socket) {
roster: roverManager.getRoster(), roster: roverManager.getRoster(),
assignment: assignmentService.describeAssignment(socket?.id || ''), assignment: assignmentService.describeAssignment(socket?.id || ''),
activeDrivers: getActiveDrivers(), activeDrivers: getActiveDrivers(),
roomCameras: getRoomCameras(),
}; };
} }
@@ -78,6 +80,11 @@ turnEvents.on('activeDriver', () => {
syncAll(); syncAll();
}); });
roomCameraEvents.on('update', () => {
logger.info('Room camera change detected; syncing all clients');
syncAll();
});
// sync all sockets 5 seconds // sync all sockets 5 seconds
setInterval(() => { setInterval(() => {
logger.info('Periodic session sync for all clients'); logger.info('Periodic session sync for all clients');
+17 -13
View File
@@ -24,10 +24,10 @@ function getPathPrefix() {
const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, ''); const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, '');
const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : []; const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : [];
function extractRoverId(path) { function extractStreamInfo(path) {
const segments = (path || '').split('/').filter(Boolean); const segments = (path || '').split('/').filter(Boolean);
if (!segments.length) { if (!segments.length) {
return ''; return null;
} }
let start = 0; let start = 0;
@@ -43,10 +43,14 @@ function extractRoverId(path) {
end -= 1; end -= 1;
} }
if (end - start !== 1) { const remaining = segments.slice(start, end);
return ''; if (remaining.length === 1) {
return { type: 'rover', id: remaining[0] || '' };
} }
return segments[start] || ''; if (remaining.length === 2 && remaining[0] === 'room') {
return { type: 'room', id: remaining[1] || '' };
}
return null;
} }
function canView(socket) { function canView(socket) {
@@ -67,17 +71,17 @@ app.post('/mediamtx/auth', (req, res) => {
const body = req.body || {}; const body = req.body || {};
const path = (body.path || '').replace(/^\//, ''); const path = (body.path || '').replace(/^\//, '');
const sessionId = body.user; const sessionId = body.user;
const roverId = extractRoverId(path); const streamInfo = extractStreamInfo(path);
logger.info('video auth request', { path: body.path, sessionId, roverId }); logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo });
if (!sessionId || !roverId) { if (!sessionId || !streamInfo?.id) {
logger.warn('auth missing session or rover (session=%s path=%s)', sessionId, path); logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
return res.status(401).end(); return res.status(401).end();
} }
const info = videoSessions.getSession(sessionId); const info = videoSessions.getSession(sessionId);
if (!info || info.roverId !== roverId) { if (!info || info.sourceType !== streamInfo.type || info.sourceId !== streamInfo.id) {
logger.warn('invalid session %s for rover %s', sessionId, roverId); logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
return res.status(401).end(); return res.status(401).end();
} }
const socket = io.sockets.sockets.get(info.socketId); const socket = io.sockets.sockets.get(info.socketId);
@@ -89,8 +93,8 @@ app.post('/mediamtx/auth', (req, res) => {
return res.status(401).end(); return res.status(401).end();
} }
const role = getRole(socket); const role = getRole(socket);
if (role !== 'spectator' && !isAdmin(socket)) { if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) {
if (!roverManager.isDriver(roverId, socket)) { if (!roverManager.isDriver(streamInfo.id, socket)) {
return res.status(401).end(); return res.status(401).end();
} }
} }
+12 -3
View File
@@ -1,12 +1,21 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../globals/io');
const sessions = new Map(); // sessionId -> { socketId, roverId } const sessions = new Map(); // sessionId -> { socketId, sourceType, sourceId }
const socketSessions = new Map(); // socketId -> Set(sessionId) const socketSessions = new Map(); // socketId -> Set(sessionId)
function createSession(socket, roverId) { function validateSource(source = {}) {
const { type, id } = source;
if (!type || !id) {
throw new Error('Invalid video source');
}
return { type, id };
}
function createSession(socket, source) {
const { type, id } = validateSource(source);
const sessionId = uuidv4(); const sessionId = uuidv4();
sessions.set(sessionId, { socketId: socket.id, roverId }); sessions.set(sessionId, { socketId: socket.id, sourceType: type, sourceId: id });
if (!socketSessions.has(socket.id)) { if (!socketSessions.has(socket.id)) {
socketSessions.set(socket.id, new Set()); socketSessions.set(socket.id, new Set());
} }
+64 -17
View File
@@ -4,12 +4,13 @@ const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService'); const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const videoSessions = require('./videoSessions'); const videoSessions = require('./videoSessions');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { getRoomCamera } = require('./roomCameraService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const config = loadConfig(); const config = loadConfig();
const mediaConfig = config.media || {}; const mediaConfig = config.media || {};
function buildWhepUrl(roverId) { function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl; const base = mediaConfig.whepBaseUrl;
if (!base) { if (!base) {
return ''; return '';
@@ -21,12 +22,22 @@ function buildWhepUrl(roverId) {
} catch (err) { } catch (err) {
// leave prefix as-is when URL parsing fails; fall back to string cleanup below // leave prefix as-is when URL parsing fails; fall back to string cleanup below
} }
const cleanBase = prefix.replace(/\/+$/, ''); return prefix.replace(/\/+$/, '');
const encodedId = encodeURIComponent(roverId);
return `${cleanBase}/${encodedId}/whep`;
} }
function canView(socket, roverId) { function buildWhepUrlForSource(source) {
const cleanBase = getMediaPrefix();
if (!cleanBase) return '';
const segments = [];
if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id));
} else {
segments.push(encodeURIComponent(source.id));
}
return `${cleanBase}/${segments.join('/')}/whep`;
}
function passesMode(socket) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return false; return false;
@@ -34,6 +45,13 @@ function canView(socket, roverId) {
if (mode === MODES.ADMIN && !isAdmin(socket)) { if (mode === MODES.ADMIN && !isAdmin(socket)) {
return false; return false;
} }
return true;
}
function canViewRover(socket, roverId) {
if (!passesMode(socket)) {
return false;
}
const role = getRole(socket); const role = getRole(socket);
if (role === 'spectator' || isAdmin(socket)) { if (role === 'spectator' || isAdmin(socket)) {
return true; return true;
@@ -41,27 +59,56 @@ function canView(socket, roverId) {
return roverManager.isDriver(roverId, socket); return roverManager.isDriver(roverId, socket);
} }
function canViewRoomCamera(socket) {
return passesMode(socket);
}
function normalizeRequest(payload = {}) {
if (!payload) return null;
if (payload.type && payload.id) {
return { type: payload.type, id: String(payload.id) };
}
if (payload.roverId) {
return { type: 'rover', id: String(payload.roverId) };
}
if (payload.roomCameraId) {
return { type: 'room', id: String(payload.roomCameraId) };
}
return null;
}
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('video:request', ({ roverId } = {}, cb = () => {}) => { socket.on('video:request', (payload = {}, cb = () => {}) => {
try { try {
if (!roverId) { const target = normalizeRequest(payload);
throw new Error('roverId required'); if (!target) {
throw new Error('video source required');
} }
if (!roverManager.rovers.has(roverId)) { if (target.type === 'rover') {
throw new Error('Rover offline'); if (!roverManager.rovers.has(target.id)) {
throw new Error('Rover offline');
}
if (!canViewRover(socket, target.id)) {
throw new Error('Not authorized for video');
}
} else if (target.type === 'room') {
if (!getRoomCamera(target.id)) {
throw new Error('Unknown room camera');
}
if (!canViewRoomCamera(socket)) {
throw new Error('Not authorized for room camera');
}
} else {
throw new Error('Unsupported video source');
} }
if (!canView(socket, roverId)) { const url = buildWhepUrlForSource(target);
throw new Error('Not authorized for video');
}
const url = buildWhepUrl(roverId);
if (!url) { if (!url) {
throw new Error('Server video base URL missing'); throw new Error('Server video base URL missing');
} }
const sessionId = videoSessions.createSession(socket, roverId); const sessionId = videoSessions.createSession(socket, target);
cb({ url, token: sessionId }); cb({ url, token: sessionId, type: target.type, id: target.id });
} catch (err) { } catch (err) {
logger.warn('video request failed: %s', err.message); logger.warn('video request failed: %s', err.message);
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
});
+48 -4
View File
@@ -1,8 +1,52 @@
export default function RoomCameraPanel() { import { useSession } from '../context/SessionContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js';
import VideoTile from './VideoTile.jsx';
function EmptyState() {
return ( return (
<div className="panel-section min-h-[8rem] space-y-0.5 text-base"> <div className="panel-section space-y-0.5 text-sm">
<p className="text-center text-sm text-slate-400">Room camera</p> <p className="text-center text-slate-400">No room cameras configured.</p>
<p className="text-center text-slate-200">Feed placeholder. Wire upcoming room cam here.</p> <p className="text-center text-slate-500">Add entries to server config to populate this list.</p>
</div> </div>
); );
} }
export default function RoomCameraPanel() {
const { session } = useSession();
const cameras = session?.roomCameras || [];
const sourceDescriptors = cameras.map((camera) => ({ type: 'room', id: camera.id, key: `room:${camera.id}` }));
const videoSources = useVideoRequests(sourceDescriptors);
if (cameras.length === 0) {
return <EmptyState />;
}
return (
<section className="panel-section space-y-0.5 text-base">
<header className="flex items-center justify-between text-sm text-slate-400">
<p>Room cameras</p>
<span>{cameras.length}</span>
</header>
<div className="grid gap-0.5 sm:grid-cols-2">
{cameras.map((camera) => {
const key = `room:${camera.id}`;
const sessionInfo = videoSources[key];
return (
<article key={camera.id} className="space-y-0.5 rounded border border-slate-800 bg-zinc-950 p-0.5">
<header className="space-y-0.5">
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
</header>
<VideoTile
sessionInfo={sessionInfo}
label={camera.name || camera.id}
forceMute
showBatteryBar={false}
/>
</article>
);
})}
</div>
</section>
);
}
+22 -4
View File
@@ -4,7 +4,14 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
const RESTART_DELAY_MS = 2000; const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000; const UNMUTE_RETRY_MS = 3000;
export default function VideoTile({ sessionInfo, label, forceMute = false, telemetryFrame, batteryConfig }) { export default function VideoTile({
sessionInfo,
label,
forceMute = false,
telemetryFrame,
batteryConfig,
showBatteryBar = true,
}) {
const videoRef = useRef(null); const videoRef = useRef(null);
const restartTimer = useRef(null); const restartTimer = useRef(null);
const unmuteTimer = useRef(null); const unmuteTimer = useRef(null);
@@ -27,7 +34,6 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
: Object.entries(wheelOvercurrents) : Object.entries(wheelOvercurrents)
.filter(([, active]) => Boolean(active)) .filter(([, active]) => Boolean(active))
.map(([key]) => key); .map(([key]) => key);
const overcurrentActive = overcurrentMotors.length > 0;
const scheduleRestart = useCallback(() => { const scheduleRestart = useCallback(() => {
clearTimeout(restartTimer.current); clearTimeout(restartTimer.current);
@@ -147,6 +153,14 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
? `${status} (${detail})` ? `${status} (${detail})`
: status; : status;
const renderStatusSection = () => (
<div className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{renderedStatus}</span>
</div>
</div>
);
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="relative w-full overflow-hidden bg-black aspect-video"> <div className="relative w-full overflow-hidden bg-black aspect-video">
@@ -161,12 +175,16 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
<HudOverlay frame={telemetryFrame} label={label}/> <HudOverlay frame={telemetryFrame} label={label}/>
<OvercurrentOverlay motors={overcurrentMotors} /> <OvercurrentOverlay motors={overcurrentMotors} />
</div> </div>
<BatteryBar charge={batteryCharge} config={batteryConfig} label={label} status={renderedStatus} /> {showBatteryBar ? (
<BatteryBar charge={batteryCharge} config={batteryConfig} label={label} status={renderedStatus} />
) : (
renderStatusSection()
)}
</div> </div>
); );
} }
function BatteryBar({ charge, config, label, status }) { function BatteryBar({ charge, config, status }) {
const full = config?.Full; const full = config?.Full;
const warn = config?.Warn; const warn = config?.Warn;
const urgent = config?.Urgent ?? null; const urgent = config?.Urgent ?? null;
+63 -12
View File
@@ -1,23 +1,75 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
export function useVideoRequests(roverIds = []) { function normalizeEntry(entry) {
if (!entry) return null;
if (typeof entry === 'string') {
const id = String(entry).trim();
if (!id) return null;
return { type: 'rover', id, key: id };
}
if (typeof entry === 'object') {
if (entry.type && entry.id) {
const id = String(entry.id);
return { type: entry.type, id, key: entry.key || `${entry.type}:${id}` };
}
if (entry.roverId) {
const id = String(entry.roverId);
return { type: 'rover', id, key: entry.key || id };
}
if (entry.roomCameraId) {
const id = String(entry.roomCameraId);
return { type: 'room', id, key: entry.key || `room:${id}` };
}
}
return null;
}
function dedupeEntries(entries = []) {
const seen = new Set();
const unique = [];
entries.forEach((entry) => {
if (!entry?.key || seen.has(entry.key)) {
return;
}
seen.add(entry.key);
unique.push(entry);
});
return unique.sort((a, b) => a.key.localeCompare(b.key));
}
export function useVideoRequests(sourceList = []) {
const socket = useSocket(); const socket = useSocket();
const [sources, setSources] = useState({}); const [sources, setSources] = useState({});
const normalizedKey = Array.isArray(roverIds) ? roverIds.filter(Boolean).join('|') : ''; const normalizedEntries = useMemo(() => {
if (!Array.isArray(sourceList)) {
return [];
}
return dedupeEntries(sourceList.map(normalizeEntry).filter(Boolean));
}, [sourceList]);
const normalizedKey = useMemo(
() => normalizedEntries.map((entry) => `${entry.type}:${entry.id}:${entry.key}`).join('|'),
[normalizedEntries],
);
const entriesRef = useRef([]);
useEffect(() => {
entriesRef.current = normalizedEntries;
}, [normalizedEntries]);
useEffect(() => { useEffect(() => {
if (!normalizedKey) { if (!normalizedKey) {
return undefined; return undefined;
} }
const ids = normalizedKey.split('|'); const entries = entriesRef.current;
let cancelled = false; let cancelled = false;
ids.forEach((roverId) => { entries.forEach((entry) => {
socket.emit('video:request', { roverId }, (resp = {}) => { const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
socket.emit('video:request', payload, (resp = {}) => {
if (cancelled) return; if (cancelled) return;
setSources((prev) => ({ setSources((prev) => ({
...prev, ...prev,
[roverId]: resp, [entry.key]: resp,
})); }));
}); });
}); });
@@ -29,15 +81,14 @@ export function useVideoRequests(roverIds = []) {
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!normalizedKey) return {}; if (!normalizedKey) return {};
const ids = normalizedKey.split('|');
const next = {}; const next = {};
ids.forEach((id) => { normalizedEntries.forEach((entry) => {
if (sources[id]) { if (sources[entry.key]) {
next[id] = sources[id]; next[entry.key] = sources[entry.key];
} }
}); });
return next; return next;
}, [normalizedKey, sources]); }, [normalizedKey, sources, normalizedEntries]);
return filtered; return filtered;
} }