From ea59a196bbf929c900c6feda47aad9ca071da0db Mon Sep 17 00:00:00 2001 From: legop3 Date: Thu, 20 Nov 2025 15:21:13 -0500 Subject: [PATCH] ugh --- plans/room_cameras_outline.md | 7 +- server/config.example.yaml | 7 ++ server/src/services/roomCameraService.js | 52 +++++++++++++++ server/src/services/sessionService.js | 7 ++ server/src/services/videoAuthService.js | 30 +++++---- server/src/services/videoSessions.js | 15 ++++- server/src/services/videoSocketService.js | 81 ++++++++++++++++++----- webui/src/components/RoomCameraPanel.jsx | 52 +++++++++++++-- webui/src/components/VideoTile.jsx | 26 ++++++-- webui/src/hooks/useVideoRequests.js | 75 +++++++++++++++++---- 10 files changed, 296 insertions(+), 56 deletions(-) create mode 100644 server/src/services/roomCameraService.js diff --git a/plans/room_cameras_outline.md b/plans/room_cameras_outline.md index c0e877be..a96b2ae8 100644 --- a/plans/room_cameras_outline.md +++ b/plans/room_cameras_outline.md @@ -5,9 +5,10 @@ - add support for a path of room cameras on mediamtx - /room/ - CANNOT interfere with rover cameras (/) -- 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 - 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 \ No newline at end of file +- automatically add a room camera player for each room camera +- make a component that shows all room cameras \ No newline at end of file diff --git a/server/config.example.yaml b/server/config.example.yaml index 2934c1d6..62763c74 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -12,3 +12,10 @@ media: # http:////whep # Example: 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." diff --git a/server/src/services/roomCameraService.js b/server/src/services/roomCameraService.js new file mode 100644 index 00000000..ba942a3f --- /dev/null +++ b/server/src/services/roomCameraService.js @@ -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, +}; diff --git a/server/src/services/sessionService.js b/server/src/services/sessionService.js index c77d5480..13c46578 100644 --- a/server/src/services/sessionService.js +++ b/server/src/services/sessionService.js @@ -6,6 +6,7 @@ const roverManager = require('./roverManager'); const { managerEvents } = roverManager; const assignmentService = require('./assignmentService'); const { getActiveDrivers, turnEvents } = require('./turnService'); +const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); function buildSession(socket) { return { @@ -15,6 +16,7 @@ function buildSession(socket) { roster: roverManager.getRoster(), assignment: assignmentService.describeAssignment(socket?.id || ''), activeDrivers: getActiveDrivers(), + roomCameras: getRoomCameras(), }; } @@ -78,6 +80,11 @@ turnEvents.on('activeDriver', () => { syncAll(); }); +roomCameraEvents.on('update', () => { + logger.info('Room camera change detected; syncing all clients'); + syncAll(); +}); + // sync all sockets 5 seconds setInterval(() => { logger.info('Periodic session sync for all clients'); diff --git a/server/src/services/videoAuthService.js b/server/src/services/videoAuthService.js index ad811280..901a0ec5 100644 --- a/server/src/services/videoAuthService.js +++ b/server/src/services/videoAuthService.js @@ -24,10 +24,10 @@ function getPathPrefix() { const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, ''); const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : []; -function extractRoverId(path) { +function extractStreamInfo(path) { const segments = (path || '').split('/').filter(Boolean); if (!segments.length) { - return ''; + return null; } let start = 0; @@ -43,10 +43,14 @@ function extractRoverId(path) { end -= 1; } - if (end - start !== 1) { - return ''; + const remaining = segments.slice(start, end); + 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) { @@ -67,17 +71,17 @@ app.post('/mediamtx/auth', (req, res) => { const body = req.body || {}; const path = (body.path || '').replace(/^\//, ''); const sessionId = body.user; - const roverId = extractRoverId(path); - logger.info('video auth request', { path: body.path, sessionId, roverId }); + const streamInfo = extractStreamInfo(path); + logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo }); - if (!sessionId || !roverId) { - logger.warn('auth missing session or rover (session=%s path=%s)', sessionId, path); + if (!sessionId || !streamInfo?.id) { + logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path); return res.status(401).end(); } const info = videoSessions.getSession(sessionId); - if (!info || info.roverId !== roverId) { - logger.warn('invalid session %s for rover %s', sessionId, roverId); + if (!info || info.sourceType !== streamInfo.type || info.sourceId !== streamInfo.id) { + logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id); return res.status(401).end(); } const socket = io.sockets.sockets.get(info.socketId); @@ -89,8 +93,8 @@ app.post('/mediamtx/auth', (req, res) => { return res.status(401).end(); } const role = getRole(socket); - if (role !== 'spectator' && !isAdmin(socket)) { - if (!roverManager.isDriver(roverId, socket)) { + if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) { + if (!roverManager.isDriver(streamInfo.id, socket)) { return res.status(401).end(); } } diff --git a/server/src/services/videoSessions.js b/server/src/services/videoSessions.js index 6fda0db7..2f0376ab 100644 --- a/server/src/services/videoSessions.js +++ b/server/src/services/videoSessions.js @@ -1,12 +1,21 @@ const { v4: uuidv4 } = require('uuid'); 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) -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(); - sessions.set(sessionId, { socketId: socket.id, roverId }); + sessions.set(sessionId, { socketId: socket.id, sourceType: type, sourceId: id }); if (!socketSessions.has(socket.id)) { socketSessions.set(socket.id, new Set()); } diff --git a/server/src/services/videoSocketService.js b/server/src/services/videoSocketService.js index 276700d7..7a1a94d8 100644 --- a/server/src/services/videoSocketService.js +++ b/server/src/services/videoSocketService.js @@ -4,12 +4,13 @@ const { getMode, MODES } = require('./modeManager'); const { isAdmin, isLockdownAdmin, getRole } = require('./roleService'); const videoSessions = require('./videoSessions'); const roverManager = require('./roverManager'); +const { getRoomCamera } = require('./roomCameraService'); const { loadConfig } = require('../helpers/configLoader'); const config = loadConfig(); const mediaConfig = config.media || {}; -function buildWhepUrl(roverId) { +function getMediaPrefix() { const base = mediaConfig.whepBaseUrl; if (!base) { return ''; @@ -21,12 +22,22 @@ function buildWhepUrl(roverId) { } catch (err) { // leave prefix as-is when URL parsing fails; fall back to string cleanup below } - const cleanBase = prefix.replace(/\/+$/, ''); - const encodedId = encodeURIComponent(roverId); - return `${cleanBase}/${encodedId}/whep`; + return prefix.replace(/\/+$/, ''); } -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(); if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { return false; @@ -34,6 +45,13 @@ function canView(socket, roverId) { if (mode === MODES.ADMIN && !isAdmin(socket)) { return false; } + return true; +} + +function canViewRover(socket, roverId) { + if (!passesMode(socket)) { + return false; + } const role = getRole(socket); if (role === 'spectator' || isAdmin(socket)) { return true; @@ -41,27 +59,56 @@ function canView(socket, roverId) { 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) => { - socket.on('video:request', ({ roverId } = {}, cb = () => {}) => { + socket.on('video:request', (payload = {}, cb = () => {}) => { try { - if (!roverId) { - throw new Error('roverId required'); + const target = normalizeRequest(payload); + if (!target) { + throw new Error('video source required'); } - if (!roverManager.rovers.has(roverId)) { - throw new Error('Rover offline'); + if (target.type === 'rover') { + 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)) { - throw new Error('Not authorized for video'); - } - const url = buildWhepUrl(roverId); + const url = buildWhepUrlForSource(target); if (!url) { throw new Error('Server video base URL missing'); } - const sessionId = videoSessions.createSession(socket, roverId); - cb({ url, token: sessionId }); + const sessionId = videoSessions.createSession(socket, target); + cb({ url, token: sessionId, type: target.type, id: target.id }); } catch (err) { logger.warn('video request failed: %s', err.message); cb({ error: err.message }); } }); -}); diff --git a/webui/src/components/RoomCameraPanel.jsx b/webui/src/components/RoomCameraPanel.jsx index 203cca2e..40ea5014 100644 --- a/webui/src/components/RoomCameraPanel.jsx +++ b/webui/src/components/RoomCameraPanel.jsx @@ -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 ( -
-

Room camera

-

Feed placeholder. Wire upcoming room cam here.

+
+

No room cameras configured.

+

Add entries to server config to populate this list.

); } + +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 ; + } + + return ( +
+
+

Room cameras

+ {cameras.length} +
+
+ {cameras.map((camera) => { + const key = `room:${camera.id}`; + const sessionInfo = videoSources[key]; + return ( +
+
+

{camera.name || camera.id}

+ {camera.description &&

{camera.description}

} +
+ +
+ ); + })} +
+
+ ); +} diff --git a/webui/src/components/VideoTile.jsx b/webui/src/components/VideoTile.jsx index 15c472a7..6099da24 100644 --- a/webui/src/components/VideoTile.jsx +++ b/webui/src/components/VideoTile.jsx @@ -4,7 +4,14 @@ import { WhepPlayer } from '../lib/whepPlayer.js'; const RESTART_DELAY_MS = 2000; 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 restartTimer = useRef(null); const unmuteTimer = useRef(null); @@ -27,7 +34,6 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem : Object.entries(wheelOvercurrents) .filter(([, active]) => Boolean(active)) .map(([key]) => key); - const overcurrentActive = overcurrentMotors.length > 0; const scheduleRestart = useCallback(() => { clearTimeout(restartTimer.current); @@ -147,6 +153,14 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem ? `${status} (${detail})` : status; + const renderStatusSection = () => ( +
+
+ {renderedStatus} +
+
+ ); + return (
@@ -161,12 +175,16 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
- + {showBatteryBar ? ( + + ) : ( + renderStatusSection() + )}
); } -function BatteryBar({ charge, config, label, status }) { +function BatteryBar({ charge, config, status }) { const full = config?.Full; const warn = config?.Warn; const urgent = config?.Urgent ?? null; diff --git a/webui/src/hooks/useVideoRequests.js b/webui/src/hooks/useVideoRequests.js index 1c4d9cda..3a2fb714 100644 --- a/webui/src/hooks/useVideoRequests.js +++ b/webui/src/hooks/useVideoRequests.js @@ -1,23 +1,75 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; 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 [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(() => { if (!normalizedKey) { return undefined; } - const ids = normalizedKey.split('|'); + const entries = entriesRef.current; let cancelled = false; - ids.forEach((roverId) => { - socket.emit('video:request', { roverId }, (resp = {}) => { + entries.forEach((entry) => { + const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id }; + socket.emit('video:request', payload, (resp = {}) => { if (cancelled) return; setSources((prev) => ({ ...prev, - [roverId]: resp, + [entry.key]: resp, })); }); }); @@ -29,15 +81,14 @@ export function useVideoRequests(roverIds = []) { const filtered = useMemo(() => { if (!normalizedKey) return {}; - const ids = normalizedKey.split('|'); const next = {}; - ids.forEach((id) => { - if (sources[id]) { - next[id] = sources[id]; + normalizedEntries.forEach((entry) => { + if (sources[entry.key]) { + next[entry.key] = sources[entry.key]; } }); return next; - }, [normalizedKey, sources]); + }, [normalizedKey, sources, normalizedEntries]); return filtered; }