mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
layoit
This commit is contained in:
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
@@ -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-BqgbVRB7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C8Jiquik.css">
|
||||
<script type="module" crossorigin src="/assets/index-BrPsNUkx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DZL3Zaf5.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+11
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-joystick-component": "^6.2.1",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3390,6 +3391,16 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-joystick-component": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-joystick-component/-/react-joystick-component-6.2.1.tgz",
|
||||
"integrity": "sha512-0G5Y5aX4hNuXB3xJCwz6Q+nYQOtC6kprNGKmZxmfoPvhepNYUiid0DbLEGZxmr/UKip3S/LUbcQUobtRCuB8IQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.2",
|
||||
"react-dom": ">=17.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-joystick-component": "^6.2.1",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+144
-23
@@ -1,29 +1,71 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from './context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from './context/TelemetryContext.jsx';
|
||||
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||
import VideoTile from './components/VideoTile.jsx';
|
||||
import DrivePanel from './components/DrivePanel.jsx';
|
||||
import AlertFeed from './components/AlertFeed.jsx';
|
||||
import AdminPanel from './components/AdminPanel.jsx';
|
||||
import MobileControls, { MobileJoystick, AuxMotorControls } from './components/MobileControls.jsx';
|
||||
import { DriveControlProvider } from './context/DriveControlContext.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';
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className={`rounded-full px-3 py-1 font-medium ${color}`}>
|
||||
<div className="flex flex-wrap items-center gap-1 text-[0.65rem] uppercase tracking-[0.2em]">
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${color}`}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-800/80 px-3 py-1 text-slate-200">
|
||||
Role: {role || 'unknown'}
|
||||
<span className="rounded-full bg-slate-800/80 px-2 py-0.5 text-slate-200">
|
||||
Role {role || 'unknown'}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-800/80 px-3 py-1 text-slate-200">
|
||||
Mode: {mode || '--'}
|
||||
<span className="rounded-full bg-slate-800/80 px-2 py-0.5 text-slate-200">
|
||||
Mode {mode || '--'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomCameraPanel() {
|
||||
return (
|
||||
<div className="min-h-[8rem] rounded-lg border border-slate-800 bg-slate-900/70 p-2 text-[0.75rem] text-slate-400">
|
||||
<p className="text-center text-xs uppercase tracking-[0.3em] text-slate-500">Room camera</p>
|
||||
<p className="mt-2 text-center text-sm text-slate-300">Feed placeholder. Wire upcoming room cam here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
if (typeof window === 'undefined') return 'desktop';
|
||||
return window.innerWidth >= 1024
|
||||
? 'desktop'
|
||||
: window.innerWidth > window.innerHeight
|
||||
? 'mobile-landscape'
|
||||
: 'mobile-portrait';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function updateMode() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const { innerWidth, innerHeight } = window;
|
||||
if (innerWidth >= 1024) {
|
||||
setMode('desktop');
|
||||
} else if (innerWidth > innerHeight) {
|
||||
setMode('mobile-landscape');
|
||||
} else {
|
||||
setMode('mobile-portrait');
|
||||
}
|
||||
}
|
||||
updateMode();
|
||||
window.addEventListener('resize', updateMode);
|
||||
return () => window.removeEventListener('resize', updateMode);
|
||||
}, []);
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
function LogPanel() {
|
||||
const { logs } = useSession();
|
||||
return (
|
||||
@@ -167,6 +209,12 @@ function DriverVideoPanel() {
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const sources = useVideoRequests(roverId ? [roverId] : []);
|
||||
const info = roverId ? sources[roverId] : null;
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const batteryConfig = useMemo(() => {
|
||||
if (!roverId) return null;
|
||||
const record = session?.roster?.find((item) => item.id === roverId);
|
||||
return record?.battery ?? null;
|
||||
}, [session?.roster, roverId]);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
@@ -179,7 +227,13 @@ function DriverVideoPanel() {
|
||||
</div>
|
||||
</header>
|
||||
{roverId ? (
|
||||
<VideoTile sessionInfo={info} label={roverId} muted={false} />
|
||||
<VideoTile
|
||||
sessionInfo={info}
|
||||
label={roverId}
|
||||
muted={false}
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Assignment required to initialize video.</p>
|
||||
)}
|
||||
@@ -252,28 +306,95 @@ function AuthPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { connected, session } = useSession();
|
||||
|
||||
function DesktopLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-50">
|
||||
<main className="mx-auto flex max-w-6xl flex-col gap-6 px-6 py-10">
|
||||
<header className="space-y-4">
|
||||
<h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1>
|
||||
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
|
||||
</header>
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)] gap-2">
|
||||
<TelemetryPanel />
|
||||
<DriverVideoPanel />
|
||||
<section className="grid gap-6 lg:grid-cols-2">
|
||||
<DrivePanel />
|
||||
</section>
|
||||
<section className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
<RosterPanel />
|
||||
</div>
|
||||
<RoomCameraPanel />
|
||||
<div className="flex flex-col gap-2">
|
||||
<AssignmentCard />
|
||||
<LogPanel />
|
||||
<SessionInspector />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobilePortraitLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DriverVideoPanel />
|
||||
<MobileControls />
|
||||
<DrivePanel />
|
||||
<TelemetryPanel />
|
||||
<RosterPanel />
|
||||
<AssignmentCard />
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
<RoomCameraPanel />
|
||||
<LogPanel />
|
||||
<SessionInspector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLandscapeLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.2fr)_minmax(0,0.9fr)] gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DrivePanel />
|
||||
<AuxMotorControls />
|
||||
</div>
|
||||
<DriverVideoPanel />
|
||||
<div className="flex flex-col gap-2">
|
||||
<MobileJoystick />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<RosterPanel />
|
||||
<AssignmentCard />
|
||||
<DrivePanel />
|
||||
<TelemetryPanel />
|
||||
<AuthPanel />
|
||||
<SessionInspector />
|
||||
<AdminPanel />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<RoomCameraPanel />
|
||||
<LogPanel />
|
||||
<SessionInspector />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { connected, session } = useSession();
|
||||
const layout = useLayoutMode();
|
||||
const renderedLayout = layout === 'desktop' ? <DesktopLayout /> : layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-slate-50">
|
||||
<main className="mx-auto flex w-full max-w-screen-2xl flex-col gap-2 px-1 py-1">
|
||||
<DriveControlProvider>
|
||||
<div className="flex justify-end">
|
||||
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
|
||||
</div>
|
||||
{renderedLayout}
|
||||
<AlertFeed />
|
||||
</section>
|
||||
<LogPanel />
|
||||
</DriveControlProvider>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ const MODES = [
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { session, lockRover, setMode, requestControl } = useSession();
|
||||
const roster = session?.roster ?? [];
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const [lockStates, setLockStates] = useState({});
|
||||
|
||||
const isAdmin =
|
||||
|
||||
@@ -1,30 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
|
||||
function AlertCard({ alert }) {
|
||||
const color =
|
||||
alert.color === 'error'
|
||||
? 'bg-red-500/10 border-red-500/40 text-red-100'
|
||||
: alert.color === 'warn'
|
||||
? 'bg-amber-500/10 border-amber-500/40 text-amber-100'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100';
|
||||
const LIFETIME_MS = 5000;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border px-4 py-3 text-sm ${color}`}>
|
||||
<p className="text-xs uppercase tracking-[0.3em] opacity-60">{alert.title || 'Alert'}</p>
|
||||
<p className="mt-1 text-base font-semibold">{alert.message}</p>
|
||||
</div>
|
||||
);
|
||||
function buildKey(alert) {
|
||||
if (alert.id) return alert.id;
|
||||
if (alert.timestamp) return `${alert.timestamp}-${alert.message}`;
|
||||
return `${alert.title || 'alert'}-${alert.message}`;
|
||||
}
|
||||
|
||||
export default function AlertFeed() {
|
||||
const { alerts } = useSession();
|
||||
const recent = useMemo(() => alerts.slice(-5).reverse(), [alerts]);
|
||||
if (recent.length === 0) return null;
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const latest = useMemo(() => alerts.slice(-5).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latest.length) return undefined;
|
||||
const interval = setInterval(() => setNow(Date.now()), 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [latest.length]);
|
||||
|
||||
const visible = latest
|
||||
.map((item) => ({
|
||||
...item,
|
||||
age: now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0),
|
||||
}))
|
||||
.filter((item) => item.age <= LIFETIME_MS);
|
||||
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<h2 className="text-lg font-semibold text-white">Recent Alerts</h2>
|
||||
<div className="mt-4 space-y-3">{recent.map((alert) => <AlertCard key={alert.id || alert.message} alert={alert} />)}</div>
|
||||
</section>
|
||||
<div className="pointer-events-none fixed top-2 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
|
||||
{visible.map((toast) => (
|
||||
<AlertToast key={toast.key} alert={toast.alert} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertToast({ alert }) {
|
||||
const colorClasses =
|
||||
alert.color === 'error'
|
||||
? 'border-red-500/50 bg-red-500/20 text-red-100'
|
||||
: alert.color === 'warn'
|
||||
? 'border-amber-500/50 bg-amber-500/20 text-amber-100'
|
||||
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100';
|
||||
return (
|
||||
<div className={`pointer-events-auto rounded-md border px-3 py-2 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
|
||||
<p className="text-[0.6rem] uppercase tracking-[0.4em] opacity-70">{alert.title || 'Alert'}</p>
|
||||
<p className="text-sm font-semibold">{alert.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +1,120 @@
|
||||
import { useDriveControls } from '../hooks/useDriveControls.js';
|
||||
import { useDriveControl } from '../context/DriveControlContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
|
||||
const oiButtons = [
|
||||
{ key: 'start', label: 'Start OI' },
|
||||
const manualOiButtons = [
|
||||
{ key: 'safe', label: 'Safe' },
|
||||
{ key: 'full', label: 'Full' },
|
||||
{ key: 'passive', label: 'Passive' },
|
||||
{ key: 'dock', label: 'Dock' },
|
||||
{ key: 'full', label: 'Full' },
|
||||
];
|
||||
|
||||
function SpeedMeter({ left, right }) {
|
||||
export default function DrivePanel() {
|
||||
const { roverId, speeds, stopMotors, sendOiCommand, runStartDockFull, seekDock } = useDriveControl();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
|
||||
const docked = Boolean(sensors.chargingSources?.homeBase);
|
||||
const charging = Boolean(
|
||||
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
|
||||
);
|
||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 rounded-2xl border border-slate-800/60 bg-slate-950/40 p-4 text-sm">
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
||||
<div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
||||
<span>Drive Control</span>
|
||||
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runStartDockFull}
|
||||
disabled={!roverId}
|
||||
className="w-full rounded border border-emerald-500/40 bg-emerald-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-emerald-200 disabled:opacity-40"
|
||||
>
|
||||
Enable Driving Mode
|
||||
</button>
|
||||
<p className="mt-1 text-[0.65rem] text-slate-400">Runs Start → Dock → Full commands to ready the rover.</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-[0.6rem]">
|
||||
<StatusPill label="Driving mode" active={drivingMode} />
|
||||
{updated && <span className="text-slate-500">Updated {updated}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={seekDock}
|
||||
disabled={!roverId}
|
||||
className="w-full rounded border border-cyan-500/40 bg-cyan-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-cyan-200 disabled:opacity-40"
|
||||
>
|
||||
Seek Dock
|
||||
</button>
|
||||
<p className="mt-1 text-[0.65rem] text-slate-400">
|
||||
Point the rover straight at the dock, about one foot away, before triggering.
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
|
||||
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
|
||||
</div>
|
||||
</div>
|
||||
<SpeedRow left={speeds.left} right={speeds.right} />
|
||||
<div>
|
||||
<p className="text-[0.6rem] uppercase tracking-[0.3em] text-slate-500">Manual OI</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{manualOiButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.key}
|
||||
type="button"
|
||||
onClick={() => sendOiCommand(btn.key)}
|
||||
disabled={!roverId}
|
||||
className="rounded border border-slate-800 px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.2em] text-slate-200 disabled:opacity-30"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopMotors}
|
||||
disabled={!roverId}
|
||||
className="flex-1 rounded border border-red-500/50 bg-red-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-red-200 disabled:opacity-40"
|
||||
>
|
||||
Stop Motors
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.6rem] text-slate-500">Sensor streaming auto-starts after each OI change.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedRow({ left, right }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 rounded border border-slate-900 bg-black/50 p-1 text-[0.7rem]">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Left</p>
|
||||
<p className="text-2xl font-semibold text-white">{left}</p>
|
||||
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Left</p>
|
||||
<p className="font-semibold text-slate-100">{left}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Right</p>
|
||||
<p className="text-2xl font-semibold text-white">{right}</p>
|
||||
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Right</p>
|
||||
<p className="font-semibold text-slate-100">{right}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DrivePanel() {
|
||||
const { roverId, speeds, stopMotors, sendOiCommand } = useDriveControls();
|
||||
|
||||
function StatusPill({ label, active }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<header className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Drive Controls</p>
|
||||
<h2 className="text-2xl font-semibold text-white">
|
||||
{roverId ? `Driving rover ${roverId}` : 'No rover assigned'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
||||
<span>W/A/S/D: move</span>
|
||||
<span>Shift: boost</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SpeedMeter left={speeds.left} right={speeds.right} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopMotors}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
disabled={!roverId}
|
||||
>
|
||||
Stop Motors
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">OI Modes</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{oiButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.key}
|
||||
type="button"
|
||||
onClick={() => sendOiCommand(btn.key)}
|
||||
disabled={!roverId}
|
||||
className="rounded-lg border border-slate-700 px-3 py-1 text-sm font-semibold text-slate-200 disabled:opacity-50"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
Sensor streaming starts automatically after each OI change. Keep this tab focused while driving.
|
||||
</p>
|
||||
</section>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[0.55rem] uppercase tracking-[0.3em] ${
|
||||
active
|
||||
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-slate-700 bg-slate-900 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Joystick } from 'react-joystick-component';
|
||||
import { useDriveControl } from '../context/DriveControlContext.jsx';
|
||||
|
||||
function clampUnit(value = 0) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
function MobileJoystick() {
|
||||
const { roverId, driveWithVector, stopMotors } = useDriveControl();
|
||||
const disabled = !roverId;
|
||||
|
||||
const handleMove = useCallback(
|
||||
(event = {}) => {
|
||||
if (disabled) return;
|
||||
const x = clampUnit(event.x ?? 0);
|
||||
const y = clampUnit(event.y ?? 0);
|
||||
driveWithVector({ x, y });
|
||||
},
|
||||
[disabled, driveWithVector],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (disabled) return;
|
||||
driveWithVector({ x: 0, y: 0 });
|
||||
}, [disabled, driveWithVector]);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Joystick</div>
|
||||
<div className="mt-2 flex items-center justify-center">
|
||||
<Joystick
|
||||
size={120}
|
||||
baseColor="#0f172a"
|
||||
stickColor="#38bdf8"
|
||||
throttle={75}
|
||||
move={handleMove}
|
||||
stop={handleStop}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.65rem] text-slate-400">
|
||||
Drag to drive. Release to stop sending drive commands.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopMotors}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full rounded border border-slate-800 bg-black/40 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-slate-200 disabled:opacity-40"
|
||||
>
|
||||
Panic Stop
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AuxMotorControls() {
|
||||
const { roverId, setAuxMotors } = useDriveControl();
|
||||
const disabled = !roverId;
|
||||
|
||||
const runAllForward = () => {
|
||||
if (disabled) return;
|
||||
setAuxMotors({ main: 127, side: 127, vacuum: 127 });
|
||||
};
|
||||
|
||||
const stopAll = () => {
|
||||
if (disabled) return;
|
||||
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
||||
};
|
||||
|
||||
const auxButtons = [
|
||||
{ label: 'Main +', values: { main: 127 } },
|
||||
{ label: 'Main -', values: { main: -127 } },
|
||||
{ label: 'Side +', values: { side: 127 } },
|
||||
{ label: 'Side -', values: { side: -127 } },
|
||||
{ label: 'Vacuum Max', values: { vacuum: 127 } },
|
||||
{ label: 'Vacuum Off', values: { vacuum: 0 } },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Aux Motors</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={runAllForward}
|
||||
disabled={disabled}
|
||||
className="flex-1 rounded border border-emerald-500/40 bg-emerald-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-emerald-200 disabled:opacity-40"
|
||||
>
|
||||
All forward
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAll}
|
||||
disabled={disabled}
|
||||
className="flex-1 rounded border border-slate-800 bg-black/40 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-slate-200 disabled:opacity-40"
|
||||
>
|
||||
Stop all
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 text-[0.7rem]">
|
||||
{auxButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
onClick={() => !disabled && setAuxMotors(btn.values)}
|
||||
disabled={disabled}
|
||||
className="rounded border border-slate-800 bg-black/30 py-1 uppercase tracking-[0.2em] text-slate-200 disabled:opacity-30"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MobileControlsStack() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<MobileJoystick />
|
||||
<AuxMotorControls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MobileJoystick, AuxMotorControls };
|
||||
@@ -1,56 +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,
|
||||
}));
|
||||
function formatMetric(value, fallback = '--') {
|
||||
if (value == null || value === '') return fallback;
|
||||
return 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]);
|
||||
const sensors = frame?.sensors || {};
|
||||
const voltage = sensors.voltageMv != null ? (sensors.voltageMv / 1000).toFixed(2) : null;
|
||||
const current = sensors.currentMa != null ? (sensors.currentMa / 1000).toFixed(2) : null;
|
||||
const charge = sensors.batteryChargeMah;
|
||||
const capacity = sensors.batteryCapacityMah;
|
||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||
const rawSnippet = frame?.raw ? (frame.raw.length > 80 ? `${frame.raw.slice(0, 80)}…` : frame.raw) : null;
|
||||
|
||||
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>
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-200">
|
||||
<div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
||||
<span>Telemetry</span>
|
||||
<span>{updated ? `Updated ${updated}` : 'waiting'}</span>
|
||||
</div>
|
||||
{!roverId ? (
|
||||
<p className="mt-2 text-[0.75rem] text-slate-400">Assignment required to view sensors.</p>
|
||||
) : !frame ? (
|
||||
<p className="mt-2 text-[0.75rem] text-slate-400">No sensor frames yet.</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 className="mt-2 space-y-2">
|
||||
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity} mAh` : null)} />
|
||||
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
||||
<Metric label="OI Mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||
<Metric label="Voltage" value={formatMetric(voltage ? `${voltage} V` : null)} />
|
||||
<Metric label="Current" value={formatMetric(current ? `${current} A` : null)} />
|
||||
</div>
|
||||
)}
|
||||
{rawSnippet && (
|
||||
<pre className="mt-2 overflow-hidden rounded border border-slate-800 bg-black/60 p-1 text-[0.6rem] text-lime-300">
|
||||
{rawSnippet}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[0.75rem]">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<span className="font-semibold text-slate-200">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const UNMUTE_RETRY_MS = 3000;
|
||||
|
||||
export default function VideoTile({ sessionInfo, label, forceMute = false }) {
|
||||
export default function VideoTile({ sessionInfo, label, forceMute = false, telemetryFrame, batteryConfig }) {
|
||||
const videoRef = useRef(null);
|
||||
const restartTimer = useRef(null);
|
||||
const unmuteTimer = useRef(null);
|
||||
@@ -12,6 +12,13 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [restartToken, setRestartToken] = useState(0);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const sensors = telemetryFrame?.sensors;
|
||||
const batteryCharge = sensors?.batteryChargeMah ?? null;
|
||||
const batteryCapacity = sensors?.batteryCapacityMah ?? null;
|
||||
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
|
||||
const overcurrentActive = Boolean(
|
||||
wheelOvercurrents && Object.values(wheelOvercurrents).some((value) => Boolean(value)),
|
||||
);
|
||||
|
||||
const scheduleRestart = useCallback(() => {
|
||||
clearTimeout(restartTimer.current);
|
||||
@@ -22,19 +29,31 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
|
||||
(delay = 0) => {
|
||||
if (forceMute) return;
|
||||
clearTimeout(unmuteTimer.current);
|
||||
unmuteTimer.current = setTimeout(async () => {
|
||||
|
||||
const scheduleRetry = () => {
|
||||
clearTimeout(unmuteTimer.current);
|
||||
unmuteTimer.current = setTimeout(() => {
|
||||
if (!forceMute) {
|
||||
tryPlay();
|
||||
}
|
||||
}, UNMUTE_RETRY_MS);
|
||||
};
|
||||
|
||||
const tryPlay = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
try {
|
||||
video.muted = false;
|
||||
await video.play();
|
||||
setMuted(false);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
video.muted = true;
|
||||
setMuted(true);
|
||||
attemptUnmute(UNMUTE_RETRY_MS);
|
||||
scheduleRetry();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
unmuteTimer.current = setTimeout(tryPlay, delay);
|
||||
},
|
||||
[forceMute],
|
||||
);
|
||||
@@ -55,13 +74,11 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionInfo?.url || !videoRef.current) {
|
||||
setStatus('waiting');
|
||||
setDetail(null);
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
let player;
|
||||
setMuted(true);
|
||||
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
||||
const handleStatus = (nextStatus, info) => {
|
||||
if (!active) return;
|
||||
setStatus(nextStatus);
|
||||
@@ -87,6 +104,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(resetMuteId);
|
||||
player?.stop();
|
||||
};
|
||||
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]);
|
||||
@@ -97,16 +115,17 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
|
||||
}
|
||||
}, [status, sessionInfo?.url, scheduleRestart]);
|
||||
|
||||
const renderedStatus =
|
||||
status === 'error'
|
||||
? `Error: ${detail || 'unknown'}`
|
||||
: detail
|
||||
? `${status} (${detail})`
|
||||
: status;
|
||||
const renderedStatus = !sessionInfo?.url
|
||||
? 'waiting'
|
||||
: status === 'error'
|
||||
? `Error: ${detail || 'unknown'}`
|
||||
: detail
|
||||
? `${status} (${detail})`
|
||||
: status;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full overflow-hidden rounded-2xl border border-slate-800 bg-black">
|
||||
<div className="space-y-1">
|
||||
<div className="relative w-full overflow-hidden rounded-lg border border-slate-900 bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted={forceMute || muted}
|
||||
@@ -115,10 +134,121 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
|
||||
controls={false}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
<HudOverlay frame={telemetryFrame} />
|
||||
<OvercurrentOverlay active={overcurrentActive} />
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
<span className="font-semibold text-slate-200">{label}</span>{' '}
|
||||
<span>{renderedStatus}</span>
|
||||
<BatteryBar
|
||||
charge={batteryCharge}
|
||||
capacity={batteryCapacity}
|
||||
config={batteryConfig}
|
||||
/>
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
||||
<span className="text-slate-200">{label}</span> · {renderedStatus}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatteryBar({ charge, capacity, config }) {
|
||||
if (charge == null || !config?.full || config.warn == null) {
|
||||
return null;
|
||||
}
|
||||
const span = config.full - config.warn;
|
||||
if (span <= 0) return null;
|
||||
const normalized = (charge - config.warn) / span;
|
||||
const percent = Math.min(1, Math.max(0, normalized));
|
||||
const percentDisplay = Math.round(percent * 100);
|
||||
const depleted = normalized <= 0;
|
||||
const urgent = config.urgent != null && charge <= config.urgent;
|
||||
const barClass = depleted
|
||||
? 'bg-red-500 animate-pulse'
|
||||
: urgent
|
||||
? 'bg-amber-400'
|
||||
: 'bg-emerald-500';
|
||||
const capText = capacity ? `${charge}/${capacity}` : `${charge}`;
|
||||
return (
|
||||
<div className="rounded border border-slate-900 bg-slate-950/80 p-1">
|
||||
<div className="flex items-center justify-between text-[0.65rem] text-slate-300">
|
||||
<span>Battery</span>
|
||||
<span>{capText} mAh</span>
|
||||
</div>
|
||||
<div className="mt-1 h-2 w-full rounded-full bg-slate-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] ${barClass}`}
|
||||
style={{ width: `${percentDisplay}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HudOverlay({ frame }) {
|
||||
const sensors = frame?.sensors;
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Date.now()), 150);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
|
||||
|
||||
const bumperBadges = [
|
||||
{ label: 'B-L', active: bumps.bumpLeft },
|
||||
{ label: 'B-R', active: bumps.bumpRight },
|
||||
];
|
||||
const wheelBadges = [
|
||||
{ label: 'DROP L', active: bumps.wheelDropLeft },
|
||||
{ label: 'DROP R', active: bumps.wheelDropRight },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-2 text-[0.6rem] uppercase tracking-[0.3em] text-slate-200">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
pulse ? 'bg-emerald-300 shadow-[0_0_6px_rgba(16,185,129,0.9)]' : 'bg-slate-700'
|
||||
}`}
|
||||
></span>
|
||||
<span className="text-slate-400">SENSORS</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{bumperBadges.map((badge) => (
|
||||
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
{wheelBadges.map((badge) => (
|
||||
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HudBadge({ label, active }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-sm border px-1 py-0.5 text-[0.55rem] ${
|
||||
active
|
||||
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-slate-700 bg-black/40 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OvercurrentOverlay({ active }) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
|
||||
<div className="text-center text-lg font-black uppercase tracking-[0.6em] text-red-100 animate-pulse">
|
||||
Overcurrent
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useDriveControls as useDriveControlsHook } from '../hooks/useDriveControls.js';
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
const DriveControlContext = createContext(null);
|
||||
|
||||
export function DriveControlProvider({ children }) {
|
||||
const controls = useDriveControlsHook();
|
||||
return <DriveControlContext.Provider value={controls}>{children}</DriveControlContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDriveControl() {
|
||||
const context = useContext(DriveControlContext);
|
||||
if (!context) {
|
||||
throw new Error('useDriveControl must be used within DriveControlProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useSocket } from './SocketContext.jsx';
|
||||
|
||||
@@ -61,7 +63,13 @@ export function SessionProvider({ children }) {
|
||||
socket.on('log:init', handleLogInit);
|
||||
socket.on('log:entry', handleLogEntry);
|
||||
socket.on('alert:new', (payload = {}) => {
|
||||
setAlerts((prev) => [...prev.slice(-49), payload]);
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
{
|
||||
...payload,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
// src/context/SocketContext.jsx
|
||||
import { createContext, useContext } from "react";
|
||||
import { socket } from "../lib/socket";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
|
||||
import { useSocket } from './SocketContext.jsx';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* global Buffer */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
@@ -10,6 +12,16 @@ const OI_COMMANDS = {
|
||||
dock: [143],
|
||||
};
|
||||
|
||||
const AUX_LIMITS = {
|
||||
main: [-127, 127],
|
||||
side: [-127, 127],
|
||||
vacuum: [0, 127],
|
||||
};
|
||||
|
||||
const COMMAND_DELAY_MS = 200;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -51,6 +63,11 @@ function computeSpeeds(keys) {
|
||||
};
|
||||
}
|
||||
|
||||
function clampUnit(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
function shouldIgnoreEvent(event) {
|
||||
const target = event.target;
|
||||
if (!target) return false;
|
||||
@@ -66,7 +83,10 @@ function shouldIgnoreEvent(event) {
|
||||
function bytesToBase64(bytes) {
|
||||
const binary = String.fromCharCode(...bytes);
|
||||
if (typeof btoa === 'function') return btoa(binary);
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
throw new Error('No base64 encoder available');
|
||||
}
|
||||
|
||||
export function useDriveControls() {
|
||||
@@ -75,7 +95,7 @@ export function useDriveControls() {
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const keysRef = useRef(new Set());
|
||||
const lastSpeedsRef = useRef({ left: 0, right: 0 });
|
||||
const [currentSpeeds, setCurrentSpeeds] = useState(lastSpeedsRef.current);
|
||||
const [currentSpeeds, setCurrentSpeeds] = useState(() => ({ left: 0, right: 0 }));
|
||||
|
||||
const emitCommand = useCallback(
|
||||
(payload, cb) => {
|
||||
@@ -106,16 +126,49 @@ export function useDriveControls() {
|
||||
});
|
||||
}, [emitCommand, roverId]);
|
||||
|
||||
const driveWithVector = useCallback(
|
||||
({ x = 0, y = 0, boost = false } = {}) => {
|
||||
if (!roverId) return;
|
||||
const base = boost ? 400 : 250;
|
||||
const forward = clampUnit(y) * base;
|
||||
const turn = clampUnit(x) * base;
|
||||
const speeds = {
|
||||
left: clamp(Math.round(forward + turn), -500, 500),
|
||||
right: clamp(Math.round(forward - turn), -500, 500),
|
||||
};
|
||||
lastSpeedsRef.current = speeds;
|
||||
setCurrentSpeeds(speeds);
|
||||
emitCommand({
|
||||
type: 'drive',
|
||||
data: { driveDirect: speeds },
|
||||
});
|
||||
},
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
const sendMotorPwm = useCallback(
|
||||
({ main = 0, side = 0, vacuum = 0 } = {}) => {
|
||||
if (!roverId) return;
|
||||
const payload = {
|
||||
main: clamp(main, AUX_LIMITS.main[0], AUX_LIMITS.main[1]),
|
||||
side: clamp(side, AUX_LIMITS.side[0], AUX_LIMITS.side[1]),
|
||||
vacuum: clamp(vacuum, AUX_LIMITS.vacuum[0], AUX_LIMITS.vacuum[1]),
|
||||
};
|
||||
emitCommand({
|
||||
type: 'motors',
|
||||
data: { motorPwm: payload },
|
||||
});
|
||||
},
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
const stopMotors = useCallback(() => {
|
||||
if (!roverId) return;
|
||||
keysRef.current.clear();
|
||||
lastSpeedsRef.current = { left: 0, right: 0 };
|
||||
setCurrentSpeeds(lastSpeedsRef.current);
|
||||
emitCommand({
|
||||
type: 'motors',
|
||||
data: { motorPwm: { main: 0, side: 0, vacuum: 0 } },
|
||||
});
|
||||
}, [emitCommand, roverId]);
|
||||
sendMotorPwm({ main: 0, side: 0, vacuum: 0 });
|
||||
}, [roverId, sendMotorPwm]);
|
||||
|
||||
const sendOiCommand = useCallback(
|
||||
(key) => {
|
||||
@@ -131,6 +184,27 @@ export function useDriveControls() {
|
||||
[emitCommand, enableSensorStream, roverId],
|
||||
);
|
||||
|
||||
const runStartDockFull = useCallback(async () => {
|
||||
if (!roverId) return;
|
||||
for (const key of ['start', 'dock', 'full']) {
|
||||
sendOiCommand(key);
|
||||
// brief pause so the commands aren't collapsed by the rover
|
||||
await sleep(COMMAND_DELAY_MS);
|
||||
}
|
||||
}, [roverId, sendOiCommand]);
|
||||
|
||||
const seekDock = useCallback(() => {
|
||||
sendOiCommand('dock');
|
||||
}, [sendOiCommand]);
|
||||
|
||||
const setAuxMotors = useCallback(
|
||||
(values) => {
|
||||
if (!roverId) return;
|
||||
sendMotorPwm(values);
|
||||
},
|
||||
[roverId, sendMotorPwm],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roverId) {
|
||||
keysRef.current.clear();
|
||||
@@ -171,5 +245,9 @@ export function useDriveControls() {
|
||||
speeds: currentSpeeds,
|
||||
stopMotors,
|
||||
sendOiCommand,
|
||||
runStartDockFull,
|
||||
seekDock,
|
||||
setAuxMotors,
|
||||
driveWithVector,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
|
||||
export function useVideoRequests(roverIds = []) {
|
||||
const socket = useSocket();
|
||||
const [sources, setSources] = useState({});
|
||||
const ids = useMemo(() => Array.from(new Set(roverIds.filter(Boolean))), [roverIds]);
|
||||
const idsKey = ids.join('|');
|
||||
|
||||
useEffect(() => {
|
||||
const ids = Array.from(new Set(roverIds.filter(Boolean)));
|
||||
if (!ids.length) {
|
||||
setSources({});
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
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;
|
||||
@@ -32,7 +25,17 @@ export function useVideoRequests(roverIds = []) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [socket, roverIds.join('|')]);
|
||||
}, [socket, idsKey, ids]);
|
||||
const filtered = useMemo(() => {
|
||||
if (!ids.length) return {};
|
||||
const next = {};
|
||||
ids.forEach((id) => {
|
||||
if (sources[id]) {
|
||||
next[id] = sources[id];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}, [ids, sources]);
|
||||
|
||||
return sources;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* global Buffer */
|
||||
|
||||
const RTC_CONFIG = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
@@ -8,11 +10,20 @@ const RTC_CONFIG = {
|
||||
};
|
||||
|
||||
|
||||
function encodeBase64(value) {
|
||||
if (typeof btoa === 'function') {
|
||||
return btoa(value);
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(value).toString('base64');
|
||||
}
|
||||
throw new Error('No base64 encoder available');
|
||||
}
|
||||
|
||||
function buildAuthHeader(token) {
|
||||
if (!token) return {};
|
||||
const credential = `${token}:${token}`;
|
||||
const encoded =
|
||||
typeof btoa === 'function' ? btoa(credential) : Buffer.from(credential).toString('base64');
|
||||
const encoded = encodeBase64(credential);
|
||||
return { Authorization: `Basic ${encoded}` };
|
||||
}
|
||||
|
||||
@@ -54,9 +65,9 @@ export class WhepPlayer {
|
||||
this.pc = pc;
|
||||
const stream = new MediaStream();
|
||||
pc.ontrack = (event) => {
|
||||
event.streams[0]?.getTracks().forEach((track) => stream.addTrack(track));
|
||||
event.streams[0]?.getTracks().forEach((mediaTrack) => stream.addTrack(mediaTrack));
|
||||
this.video.srcObject = stream;
|
||||
if (track.kind === 'video' && 'playoutDelayHint' in event.receiver) {
|
||||
if (event.track?.kind === 'video' && 'playoutDelayHint' in event.receiver) {
|
||||
event.receiver.playoutDelayHint = 0;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user