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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-xwzFeHXC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JJWJQzRI.css">
<script type="module" crossorigin src="/assets/index-Cn-MOZwV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cbe_I3mP.css">
</head>
<body>
<div id="root"></div>
+30
View File
@@ -1,5 +1,8 @@
import { useMemo, useState } from 'react';
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 }) {
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() {
const { login, setRole } = useSession();
const [username, setUsername] = useState('');
@@ -231,9 +259,11 @@ function App() {
<h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1>
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
</header>
<DriverVideoPanel />
<section className="grid gap-6 lg:grid-cols-2">
<RosterPanel />
<AssignmentCard />
<TelemetryPanel />
<AuthPanel />
<SessionInspector />
</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(() => {
const ids = Array.from(new Set(roverIds.filter(Boolean)));
if (!ids.length) return;
if (!ids.length) {
setSources({});
return;
}
let cancelled = false;
setSources((prev) => {
const next = {};
ids.forEach((id) => {
if (prev[id]) next[id] = prev[id];
});
return next;
});
ids.forEach((roverId) => {
socket.emit('video:request', { roverId }, (resp = {}) => {
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 { SocketProvider } from './context/SocketContext.jsx'
import { SessionProvider } from './context/SessionContext.jsx'
import { TelemetryProvider } from './context/TelemetryContext.jsx'
import SpectatorApp from './spectate/SpectatorApp.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<SocketProvider>
<SessionProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />
</Routes>
</BrowserRouter>
<TelemetryProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />
</Routes>
</BrowserRouter>
</TelemetryProvider>
</SessionProvider>
</SocketProvider>
</StrictMode>,
+4 -15
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
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 VideoTile from '../components/VideoTile.jsx';
function SpectatorStatus({ connected, ready, rosterCount }) {
return (
@@ -65,19 +66,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
</p>
</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">
{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>
<VideoTile sessionInfo={videoInfo} label={rover.name} />
<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>
{sensorList.length === 0 ? (
@@ -100,7 +89,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
export default function SpectatorApp() {
const { connected, session, logs } = useSession();
const spectatorReady = useSpectatorMode();
const frames = useSensorFeed();
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
const videoSources = useVideoRequests(roster.map((rover) => rover.id));