This commit is contained in:
legop3
2025-11-15 05:20:22 -05:00
parent 50c56e7271
commit eb2e2b15d8
14 changed files with 296 additions and 66 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-xwzFeHXC.js"></script> <script type="module" crossorigin src="/assets/index-Cn-MOZwV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JJWJQzRI.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cbe_I3mP.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+30
View File
@@ -1,5 +1,8 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSession } from './context/SessionContext.jsx'; import { useSession } from './context/SessionContext.jsx';
import TelemetryPanel from './components/TelemetryPanel.jsx';
import VideoTile from './components/VideoTile.jsx';
import { useVideoRequests } from './hooks/useVideoRequests.js';
function StatusBadge({ connected, role, mode }) { function StatusBadge({ connected, role, mode }) {
const color = connected ? 'bg-emerald-500/20 text-emerald-300' : 'bg-red-500/20 text-red-200'; const color = connected ? 'bg-emerald-500/20 text-emerald-300' : 'bg-red-500/20 text-red-200';
@@ -156,6 +159,31 @@ function SessionInspector() {
); );
} }
function DriverVideoPanel() {
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const sources = useVideoRequests(roverId ? [roverId] : []);
const info = roverId ? sources[roverId] : null;
return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<header className="mb-4 flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Video Feed</p>
<h2 className="text-2xl font-semibold text-white">
{roverId ? `Rover ${roverId}` : 'No rover assigned'}
</h2>
</div>
</header>
{roverId ? (
<VideoTile sessionInfo={info} label={roverId} muted={false} />
) : (
<p className="text-sm text-slate-400">Assignment required to initialize video.</p>
)}
</section>
);
}
function AuthPanel() { function AuthPanel() {
const { login, setRole } = useSession(); const { login, setRole } = useSession();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -231,9 +259,11 @@ function App() {
<h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1> <h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1>
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} /> <StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
</header> </header>
<DriverVideoPanel />
<section className="grid gap-6 lg:grid-cols-2"> <section className="grid gap-6 lg:grid-cols-2">
<RosterPanel /> <RosterPanel />
<AssignmentCard /> <AssignmentCard />
<TelemetryPanel />
<AuthPanel /> <AuthPanel />
<SessionInspector /> <SessionInspector />
</section> </section>
+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
function formatEntries(sensors = {}) {
return Object.entries(sensors).map(([key, value]) => ({
key,
value: typeof value === 'object' ? JSON.stringify(value) : value,
}));
}
export default function TelemetryPanel() {
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const frame = useTelemetryFrame(roverId);
const entries = useMemo(() => formatEntries(frame?.sensors), [frame?.sensors]);
return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
<h2 className="text-2xl font-semibold text-white">
{roverId ? `Rover ${roverId}` : 'No rover assigned'}
</h2>
</div>
<p className="text-sm text-slate-400">
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting…'}
</p>
</header>
{entries.length === 0 ? (
<p className="mt-4 text-sm text-slate-400">
{roverId ? 'No sensor frames yet.' : 'Assignment required to view sensors.'}
</p>
) : (
<dl className="mt-4 grid gap-3 text-sm text-slate-100">
{entries.map(({ key, value }) => (
<div key={key} className="flex justify-between gap-6">
<dt className="text-slate-500">{key}</dt>
<dd className="text-right font-semibold text-white">{value}</dd>
</div>
))}
</dl>
)}
{frame?.raw && (
<div className="mt-6">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Raw frame</p>
<pre className="mt-2 max-h-32 overflow-y-auto rounded-xl bg-slate-950/60 p-3 text-xs text-lime-300">
{frame.raw}
</pre>
</div>
)}
</section>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
import { WhepPlayer } from '../lib/whepPlayer.js';
export default function VideoTile({ sessionInfo, label, muted = true }) {
const videoRef = useRef(null);
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
useEffect(() => {
let player;
let cancelled = false;
async function start() {
if (!sessionInfo?.url || !videoRef.current) {
setStatus('waiting');
return;
}
setStatus('connecting');
setError(null);
player = new WhepPlayer({
url: sessionInfo.url,
token: sessionInfo.token,
video: videoRef.current,
});
try {
await player.start();
if (!cancelled) {
setStatus('playing');
}
} catch (err) {
if (!cancelled) {
setStatus('error');
setError(err.message);
}
}
}
start();
return () => {
cancelled = true;
player?.stop();
};
}, [sessionInfo?.url, sessionInfo?.token]);
return (
<div className="space-y-2">
<div className="aspect-video w-full overflow-hidden rounded-2xl border border-slate-800 bg-black">
<video
ref={videoRef}
muted={muted}
playsInline
autoPlay
controls={false}
className="h-full w-full object-contain"
/>
</div>
<div className="text-xs text-slate-400">
<span className="font-semibold text-slate-200">{label}</span>{' '}
{status === 'error' ? <span className="text-red-400">Error: {error}</span> : <span>{status}</span>}
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
import { useSocket } from './SocketContext.jsx';
const TelemetryContext = createContext({ frames: {} });
export function TelemetryProvider({ children }) {
const socket = useSocket();
const [frames, setFrames] = useState({});
useEffect(() => {
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return;
setFrames((prev) => ({
...prev,
[roverId]: {
roverId,
sensors,
raw: frame?.data || null,
receivedAt: Date.now(),
},
}));
}
socket.on('sensorFrame', handleSensorFrame);
return () => {
socket.off('sensorFrame', handleSensorFrame);
};
}, [socket]);
const value = useMemo(() => ({ frames }), [frames]);
return <TelemetryContext.Provider value={value}>{children}</TelemetryContext.Provider>;
}
export function useTelemetryFrames() {
return useContext(TelemetryContext).frames;
}
export function useTelemetryFrame(roverId) {
const frames = useTelemetryFrames();
if (!roverId) return null;
return frames[roverId] ?? null;
}
-29
View File
@@ -1,29 +0,0 @@
import { useEffect, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
export function useSensorFeed() {
const socket = useSocket();
const [frames, setFrames] = useState({});
useEffect(() => {
function handleFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return;
setFrames((prev) => ({
...prev,
[roverId]: {
roverId,
sensors,
raw: frame?.data || null,
receivedAt: Date.now(),
},
}));
}
socket.on('sensorFrame', handleFrame);
return () => {
socket.off('sensorFrame', handleFrame);
};
}, [socket]);
return frames;
}
+11 -2
View File
@@ -7,9 +7,18 @@ export function useVideoRequests(roverIds = []) {
useEffect(() => { useEffect(() => {
const ids = Array.from(new Set(roverIds.filter(Boolean))); const ids = Array.from(new Set(roverIds.filter(Boolean)));
if (!ids.length) return; if (!ids.length) {
setSources({});
return;
}
let cancelled = false; let cancelled = false;
setSources((prev) => {
const next = {};
ids.forEach((id) => {
if (prev[id]) next[id] = prev[id];
});
return next;
});
ids.forEach((roverId) => { ids.forEach((roverId) => {
socket.emit('video:request', { roverId }, (resp = {}) => { socket.emit('video:request', { roverId }, (resp = {}) => {
if (cancelled) return; if (cancelled) return;
+67
View File
@@ -0,0 +1,67 @@
function buildAuthHeader(token) {
if (!token) return {};
const credential = `${token}:${token}`;
const encoded = typeof btoa === 'function' ? btoa(credential) : Buffer.from(credential).toString('base64');
return { Authorization: `Basic ${encoded}` };
}
export class WhepPlayer {
constructor({ url, token, video }) {
this.url = url;
this.token = token;
this.video = video;
this.pc = null;
this.abortController = null;
}
async start() {
if (!this.url || !this.video) {
throw new Error('Video target missing');
}
this.stop();
this.abortController = new AbortController();
const pc = new RTCPeerConnection();
this.pc = pc;
const stream = new MediaStream();
pc.ontrack = (event) => {
event.streams[0]?.getTracks().forEach((track) => stream.addTrack(track));
this.video.srcObject = stream;
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const response = await fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
...buildAuthHeader(this.token),
},
body: offer.sdp,
signal: this.abortController.signal,
});
if (!response.ok) {
throw new Error(`WHEP request failed: ${response.status}`);
}
const answerSdp = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
}
stop() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
if (this.pc) {
this.pc.getSenders().forEach((sender) => sender.track?.stop());
this.pc.getReceivers().forEach((receiver) => receiver.track?.stop());
this.pc.close();
this.pc = null;
}
if (this.video) {
this.video.srcObject = null;
}
}
}
+9 -6
View File
@@ -5,18 +5,21 @@ import './index.css'
import App from './App.jsx' import App from './App.jsx'
import { SocketProvider } from './context/SocketContext.jsx' import { SocketProvider } from './context/SocketContext.jsx'
import { SessionProvider } from './context/SessionContext.jsx' import { SessionProvider } from './context/SessionContext.jsx'
import { TelemetryProvider } from './context/TelemetryContext.jsx'
import SpectatorApp from './spectate/SpectatorApp.jsx' import SpectatorApp from './spectate/SpectatorApp.jsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<SocketProvider> <SocketProvider>
<SessionProvider> <SessionProvider>
<BrowserRouter> <TelemetryProvider>
<Routes> <BrowserRouter>
<Route path="/" element={<App />} /> <Routes>
<Route path="/spectate" element={<SpectatorApp />} /> <Route path="/" element={<App />} />
</Routes> <Route path="/spectate" element={<SpectatorApp />} />
</BrowserRouter> </Routes>
</BrowserRouter>
</TelemetryProvider>
</SessionProvider> </SessionProvider>
</SocketProvider> </SocketProvider>
</StrictMode>, </StrictMode>,
+4 -15
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSpectatorMode } from '../hooks/useSpectatorMode.js'; import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
import { useSensorFeed } from '../hooks/useSensorFeed.js'; import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js'; import { useVideoRequests } from '../hooks/useVideoRequests.js';
import VideoTile from '../components/VideoTile.jsx';
function SpectatorStatus({ connected, ready, rosterCount }) { function SpectatorStatus({ connected, ready, rosterCount }) {
return ( return (
@@ -65,19 +66,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'} {frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
</p> </p>
</header> </header>
<div className="aspect-video w-full rounded-2xl border border-slate-800 bg-black/60 flex items-center justify-center text-sm text-slate-400"> <VideoTile sessionInfo={videoInfo} label={rover.name} />
{videoInfo?.error ? (
<span>Video error: {videoInfo.error}</span>
) : videoInfo?.url ? (
<div className="text-center text-xs text-slate-300">
<p className="font-semibold text-white">Video feed placeholder</p>
<p>WHEP URL: {videoInfo.url}</p>
<p>Token: {videoInfo.token || 'retrieving…'}</p>
</div>
) : (
<span>Requesting video session</span>
)}
</div>
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4"> <section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p> <p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
{sensorList.length === 0 ? ( {sensorList.length === 0 ? (
@@ -100,7 +89,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
export default function SpectatorApp() { export default function SpectatorApp() {
const { connected, session, logs } = useSession(); const { connected, session, logs } = useSession();
const spectatorReady = useSpectatorMode(); const spectatorReady = useSpectatorMode();
const frames = useSensorFeed(); const frames = useTelemetryFrames();
const roster = session?.roster ?? []; const roster = session?.roster ?? [];
const videoSources = useVideoRequests(roster.map((rover) => rover.id)); const videoSources = useVideoRequests(roster.map((rover) => rover.id));