This commit is contained in:
legop3
2025-11-15 21:49:00 -05:00
parent 392e2f661b
commit edcb465630
13 changed files with 235 additions and 357 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-BrPsNUkx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DZL3Zaf5.css">
<script type="module" crossorigin src="/assets/index-CJa482Eg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BUE8ypH_.css">
</head>
<body>
<div id="root"></div>
+52 -201
View File
@@ -10,28 +10,11 @@ import MobileControls, { MobileJoystick, AuxMotorControls } from './components/M
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-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-2 py-0.5 text-slate-200">
Role {role || 'unknown'}
</span>
<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 className="min-h-[8rem] rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
<p className="text-center text-xs text-slate-400">Room camera</p>
<p className="mt-1 text-center text-sm text-slate-200">Feed placeholder. Wire upcoming room cam here.</p>
</div>
);
}
@@ -69,14 +52,12 @@ function useLayoutMode() {
function LogPanel() {
const { logs } = useSession();
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-4">
<header className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Server logs</h2>
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">
{logs.length} entries
</span>
</header>
<div className="h-64 overflow-y-auto rounded-xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300">
<div className="rounded-sm bg-[#1b1b1b] p-1 text-xs text-slate-200">
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
<span>Server logs</span>
<span>{logs.length}</span>
</div>
<div className="mt-1 h-48 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-[0.65rem] text-slate-300">
{logs.length === 0 ? (
<p>No logs yet.</p>
) : (
@@ -84,10 +65,10 @@ function LogPanel() {
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className="mb-2">
<div key={entry.id} className="mb-1">
<span className="text-slate-500">{entry.timestamp}</span>{' '}
<span className="text-cyan-300">[{entry.level}]</span>{' '}
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
<span className="text-slate-300">[{entry.level}]</span>{' '}
{entry.label && <span className="text-slate-400">[{entry.label}]</span>}{' '}
<span>{entry.message}</span>
</div>
))
@@ -97,113 +78,6 @@ function LogPanel() {
);
}
function RosterPanel() {
const { session, requestControl } = useSession();
const roster = session?.roster ?? [];
const isAdmin = session?.role === 'admin' || session?.role === 'lockdown';
const [pending, setPending] = useState({});
async function handleRequest(roverId) {
setPending((prev) => ({ ...prev, [roverId]: true }));
try {
await requestControl(roverId);
} catch (err) {
alert(err.message);
} finally {
setPending((prev) => ({ ...prev, [roverId]: false }));
}
}
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 className="text-lg font-semibold text-white">Rovers</h2>
<div className="mt-4 space-y-3">
{roster.length === 0 && <p className="text-slate-400">No rovers registered.</p>}
{roster.map((rover) => (
<div
key={rover.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-slate-950/40 px-4 py-3"
>
<div>
<p className="text-white">{rover.name}</p>
<p className="text-xs text-slate-400">Locked: {rover.locked ? 'yes' : 'no'}</p>
</div>
{isAdmin ? (
<button
type="button"
onClick={() => handleRequest(rover.id)}
disabled={pending[rover.id]}
className="rounded-full bg-blue-600 px-4 py-1 text-sm font-semibold text-white disabled:opacity-50"
>
{pending[rover.id] ? 'Requesting…' : 'Request control'}
</button>
) : (
<span className="text-xs text-slate-500">Auto-assigned</span>
)}
</div>
))}
</div>
</div>
);
}
function AssignmentCard() {
const { session, releaseControl } = useSession();
const assignment = session?.assignment;
const roverId = assignment?.roverId;
const [busy, setBusy] = useState(false);
async function handleRelease() {
if (!roverId) return;
setBusy(true);
try {
await releaseControl(roverId);
} catch (err) {
alert(err.message);
} finally {
setBusy(false);
}
}
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 className="text-lg font-semibold text-white">Assignment</h2>
{roverId ? (
<div className="mt-4 space-y-3">
<p className="text-slate-300">Currently driving rover {roverId}</p>
<button
type="button"
onClick={handleRelease}
disabled={busy}
className="rounded-full bg-red-600 px-4 py-1 text-sm font-semibold text-white disabled:opacity-50"
>
{busy ? 'Releasing…' : 'Release control'}
</button>
</div>
) : (
<div className="mt-4 text-slate-400">
<p>Status: {assignment?.status ?? 'not assigned'}</p>
{assignment?.queuePosition && (
<p>Queue position: {assignment.queuePosition}</p>
)}
</div>
)}
</div>
);
}
function SessionInspector() {
const { session } = useSession();
const formatted = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 className="text-lg font-semibold text-white">Session snapshot</h2>
<pre className="mt-4 max-h-64 overflow-y-auto text-sm text-teal-200">{formatted}</pre>
</div>
);
}
function DriverVideoPanel() {
const { session } = useSession();
const roverId = session?.assignment?.roverId;
@@ -217,25 +91,19 @@ function DriverVideoPanel() {
}, [session?.roster, roverId]);
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>
<section className="rounded-sm bg-[#1b1b1b] p-1">
{roverId ? (
<VideoTile
sessionInfo={info}
label={roverId}
muted={false}
telemetryFrame={frame}
batteryConfig={batteryConfig}
/>
<div className="min-h-[55vh]">
<VideoTile
sessionInfo={info}
label={roverId}
muted={false}
telemetryFrame={frame}
batteryConfig={batteryConfig}
/>
</div>
) : (
<p className="text-sm text-slate-400">Assignment required to initialize video.</p>
<p className="text-sm text-slate-400">Assign a rover to view the video feed.</p>
)}
</section>
);
@@ -262,17 +130,17 @@ function AuthPanel() {
}
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 className="text-lg font-semibold text-white">Authentication</h2>
<form className="mt-4 flex flex-col gap-3" onSubmit={handleLogin}>
<div className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
<p className="text-xs text-slate-400">Admin login</p>
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
<input
className="rounded-lg border border-slate-800 bg-slate-950/60 px-3 py-2 text-white"
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className="rounded-lg border border-slate-800 bg-slate-950/60 px-3 py-2 text-white"
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
placeholder="Password"
type="password"
value={password}
@@ -281,25 +149,25 @@ function AuthPanel() {
<button
type="submit"
disabled={loading}
className="rounded-lg bg-emerald-600 py-2 font-semibold text-white disabled:opacity-50"
className="rounded-sm bg-slate-200 px-1 py-1 text-xs font-semibold text-black disabled:opacity-50"
>
{loading ? 'Logging in…' : 'Login as admin'}
{loading ? 'Logging in…' : 'Login'}
</button>
</form>
<div className="mt-4 flex gap-3">
<div className="mt-1 flex gap-1 text-xs">
<button
type="button"
onClick={() => setRole('user')}
className="rounded-lg border border-slate-700 px-3 py-1 text-sm text-slate-200"
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
Driver mode
Driver
</button>
<button
type="button"
onClick={() => setRole('spectator')}
className="rounded-lg border border-slate-700 px-3 py-1 text-sm text-slate-200"
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
Spectator mode
Spectator
</button>
</div>
</div>
@@ -308,24 +176,19 @@ function AuthPanel() {
function DesktopLayout() {
return (
<div className="flex flex-col gap-2">
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)] gap-2">
<div className="flex flex-col gap-1">
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_minmax(0,0.9fr)] gap-1">
<TelemetryPanel />
<DriverVideoPanel />
<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">
<section className="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-1">
<div className="flex flex-col gap-1">
<AuthPanel />
<AdminPanel />
<RosterPanel />
</div>
<RoomCameraPanel />
<div className="flex flex-col gap-2">
<AssignmentCard />
<LogPanel />
<SessionInspector />
</div>
<LogPanel />
</section>
</div>
);
@@ -333,47 +196,41 @@ function DesktopLayout() {
function MobilePortraitLayout() {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<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">
<div className="flex flex-col gap-1">
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)] gap-1">
<div className="flex flex-col gap-1">
<DrivePanel />
<AuxMotorControls />
</div>
<DriverVideoPanel />
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<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 />
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
<div className="flex flex-col gap-1">
<AuthPanel />
<AdminPanel />
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<RoomCameraPanel />
<LogPanel />
<SessionInspector />
</div>
</section>
</div>
@@ -381,21 +238,15 @@ function MobileLandscapeLayout() {
}
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 />
</DriveControlProvider>
</main>
<DriveControlProvider>
<main className="flex w-full flex-col gap-1 px-1 py-1">{renderedLayout}</main>
<AlertFeed />
</DriveControlProvider>
</div>
);
}
+23 -29
View File
@@ -57,49 +57,43 @@ export default function AdminPanel() {
if (!isAdmin) return null;
return (
<section className="rounded-2xl border border-amber-600/40 bg-amber-500/5 p-4 text-sm text-amber-100">
<header className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-white">Admin Controls</h2>
<div className="flex items-center gap-2">
<label className="text-xs uppercase tracking-[0.3em] text-amber-300">Server mode</label>
<select
value={currentMode}
onChange={handleModeChange}
className="rounded-md border border-amber-600/40 bg-slate-950/60 px-2 py-1 text-sm text-white"
>
{MODES.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.label}
</option>
))}
</select>
</div>
</header>
<section className="rounded-sm bg-[#1b1b1b] p-1 text-xs text-slate-200">
<div className="flex items-center justify-between gap-1">
<span>Admin controls</span>
<select
value={currentMode}
onChange={handleModeChange}
className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.7rem] text-slate-200"
>
{MODES.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.label}
</option>
))}
</select>
</div>
<div className="space-y-3">
<div className="mt-1 space-y-1">
{roster.map((rover) => (
<div
key={rover.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-600/30 bg-slate-950/30 px-3 py-2"
>
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
<div>
<p className="text-white">{rover.name}</p>
<p className="text-xs text-amber-200">Locked: {lockMap[rover.id] ? 'yes' : 'no'}</p>
<p className="text-slate-100">{rover.name}</p>
<p className="text-[0.6rem] text-slate-500">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1 text-[0.6rem]">
<button
type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
className="rounded-md border border-amber-500 px-3 py-1 text-xs font-semibold text-amber-100"
className="rounded-sm bg-black/50 px-1 py-0.5 text-slate-100"
>
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button>
<button
type="button"
onClick={() => handleForceControl(rover.id)}
className="rounded-md border border-amber-500 px-3 py-1 text-xs font-semibold text-amber-100"
className="rounded-sm bg-black/50 px-1 py-0.5 text-slate-100"
>
Force Control
Force
</button>
</div>
</div>
+7 -7
View File
@@ -30,7 +30,7 @@ export default function AlertFeed() {
if (!visible.length) return null;
return (
<div className="pointer-events-none fixed top-2 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1">
{visible.map((toast) => (
<AlertToast key={toast.key} alert={toast.alert} />
))}
@@ -41,14 +41,14 @@ export default function AlertFeed() {
function AlertToast({ alert }) {
const colorClasses =
alert.color === 'error'
? 'border-red-500/50 bg-red-500/20 text-red-100'
? 'bg-red-500/30 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';
? 'bg-amber-500/30 text-amber-100'
: 'bg-emerald-500/30 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 className={`pointer-events-auto rounded-sm px-2 py-1 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
<p className="text-sm text-white">{alert.message}</p>
</div>
);
}
+23 -27
View File
@@ -19,25 +19,25 @@ export default function DrivePanel() {
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
return (
<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>
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Drive control</span>
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
</div>
<div className="mt-2 space-y-2">
<div className="mt-1 space-y-1">
<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"
className="w-full rounded-sm bg-black/50 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
>
Enable Driving Mode
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>}
<p className="mt-1 text-xs text-slate-400">Runs Start Dock Full to prep sensors.</p>
<div className="mt-1 flex flex-wrap items-center gap-1 text-[0.65rem]">
<StatusPill label="Driving" active={drivingMode} />
{updated && <span className="text-slate-500">Sensors {updated}</span>}
</div>
</div>
<div>
@@ -45,13 +45,11 @@ export default function DrivePanel() {
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"
className="w-full rounded-sm bg-black/50 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
>
Seek Dock
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>
<p className="mt-1 text-xs text-slate-400">Point the rover straight at the dock, about one foot away.</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} />
@@ -59,7 +57,7 @@ export default function DrivePanel() {
</div>
<SpeedRow left={speeds.left} right={speeds.right} />
<div>
<p className="text-[0.6rem] uppercase tracking-[0.3em] text-slate-500">Manual OI</p>
<p className="text-xs text-slate-400">Manual OI</p>
<div className="mt-1 flex flex-wrap gap-1">
{manualOiButtons.map((btn) => (
<button
@@ -67,7 +65,7 @@ export default function DrivePanel() {
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"
className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.65rem] text-slate-200 disabled:opacity-30"
>
{btn.label}
</button>
@@ -79,26 +77,26 @@ export default function DrivePanel() {
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"
className="flex-1 rounded-sm bg-red-600/60 px-1 py-1 text-xs text-white disabled:opacity-40"
>
Stop Motors
Stop motors
</button>
</div>
</div>
<p className="mt-2 text-[0.6rem] text-slate-500">Sensor streaming auto-starts after each OI change.</p>
<p className="mt-1 text-[0.65rem] 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 className="grid grid-cols-2 gap-1 rounded-sm bg-black/40 p-1 text-[0.7rem]">
<div>
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Left</p>
<p className="text-[0.6rem] text-slate-500">Left</p>
<p className="font-semibold text-slate-100">{left}</p>
</div>
<div>
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Right</p>
<p className="text-[0.6rem] text-slate-500">Right</p>
<p className="font-semibold text-slate-100">{right}</p>
</div>
</div>
@@ -108,10 +106,8 @@ function SpeedRow({ left, right }) {
function StatusPill({ label, active }) {
return (
<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'
className={`rounded-sm px-1 py-0.5 text-[0.6rem] ${
active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'
}`}
>
{label}
+13 -13
View File
@@ -27,9 +27,9 @@ function MobileJoystick() {
}, [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">
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
<div className="text-xs text-slate-400">Joystick</div>
<div className="mt-1 flex items-center justify-center">
<Joystick
size={120}
baseColor="#0f172a"
@@ -40,14 +40,14 @@ function MobileJoystick() {
disabled={disabled}
/>
</div>
<p className="mt-2 text-[0.65rem] text-slate-400">
<p className="mt-1 text-xs 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"
className="mt-1 w-full rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
Panic Stop
</button>
@@ -79,14 +79,14 @@ function AuxMotorControls() {
];
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">
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
<div className="text-xs text-slate-400">Aux motors</div>
<div className="mt-1 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"
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
All forward
</button>
@@ -94,19 +94,19 @@ function AuxMotorControls() {
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"
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
Stop all
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 text-[0.7rem]">
<div className="mt-1 grid grid-cols-2 gap-1 text-[0.75rem]">
{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"
className="rounded-sm bg-black/30 px-1 py-1 text-slate-200 disabled:opacity-30"
>
{btn.label}
</button>
@@ -118,7 +118,7 @@ function AuxMotorControls() {
export default function MobileControlsStack() {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<MobileJoystick />
<AuxMotorControls />
</div>
+67 -17
View File
@@ -1,3 +1,4 @@
import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
@@ -7,50 +8,99 @@ function formatMetric(value, fallback = '--') {
}
export default function TelemetryPanel() {
const { session } = useSession();
const { connected, session, requestControl } = useSession();
const roverId = session?.assignment?.roverId;
const frame = useTelemetryFrame(roverId);
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 voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : 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;
const roster = session?.roster ?? [];
const isAdmin = useMemo(
() => session?.role === 'admin' || session?.role === 'lockdown' || session?.role === 'lockdown-admin',
[session?.role],
);
const [pending, setPending] = useState({});
async function handleRequest(roverId) {
if (!roverId) return;
setPending((prev) => ({ ...prev, [roverId]: true }));
try {
await requestControl(roverId);
} catch (err) {
alert(err.message);
} finally {
setPending((prev) => ({ ...prev, [roverId]: false }));
}
}
return (
<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>
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
<div className="text-[0.7rem] text-slate-400">
<span>{connected ? 'online' : 'offline'}</span>
<span> · role {session?.role || 'unknown'}</span>
<span> · mode {session?.mode || '--'}</span>
{updated && <span> · sensors {updated}</span>}
</div>
{!roverId ? (
<p className="mt-2 text-[0.75rem] text-slate-400">Assignment required to view sensors.</p>
<p className="mt-1 text-[0.75rem] text-slate-400">Assign a rover to view sensors.</p>
) : !frame ? (
<p className="mt-2 text-[0.75rem] text-slate-400">No sensor frames yet.</p>
<p className="mt-1 text-[0.75rem] text-slate-400">Waiting for sensor frames</p>
) : (
<div className="mt-2 space-y-2">
<div className="mt-1 space-y-1 text-[0.75rem]">
<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)} />
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
<Metric label="Voltage" value={formatMetric(voltage)} />
<Metric label="Current" value={formatMetric(current)} />
</div>
)}
{rawSnippet && (
<pre className="mt-2 overflow-hidden rounded border border-slate-800 bg-black/60 p-1 text-[0.6rem] text-lime-300">
<pre className="mt-1 overflow-hidden rounded-sm bg-black/60 p-1 text-[0.6rem] text-lime-300">
{rawSnippet}
</pre>
)}
<div className="mt-1">
<p className="text-xs text-slate-400">Rovers</p>
{roster.length === 0 ? (
<p className="text-xs text-slate-500">No roster data.</p>
) : (
<ul className="mt-1 space-y-1 text-xs">
{roster.map((rover) => (
<li key={rover.id} className="flex items-center justify-between gap-1">
<div>
<p className="text-slate-200">{rover.name}</p>
<p className="text-[0.6rem] text-slate-500">
{rover.locked ? 'locked' : 'free'} · {rover.lastSeen ? 'seen' : 'unknown'}
</p>
</div>
{isAdmin && (
<button
type="button"
onClick={() => handleRequest(rover.id)}
disabled={pending[rover.id]}
className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.6rem] text-slate-200 disabled:opacity-40"
>
{pending[rover.id] ? '...' : 'request'}
</button>
)}
</li>
))}
</ul>
)}
</div>
</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 className="flex items-center justify-between">
<span className="text-slate-400">{label}</span>
<span className="text-slate-200">{value}</span>
</div>
);
}
+28 -43
View File
@@ -124,8 +124,8 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
: status;
return (
<div className="space-y-1">
<div className="relative w-full overflow-hidden rounded-lg border border-slate-900 bg-black">
<div className="flex flex-col gap-1">
<div className="relative w-full overflow-hidden rounded-sm bg-black">
<video
ref={videoRef}
muted={forceMute || muted}
@@ -137,21 +137,19 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
<HudOverlay frame={telemetryFrame} />
<OvercurrentOverlay active={overcurrentActive} />
</div>
<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>
<BatteryBar charge={batteryCharge} capacity={batteryCapacity} config={batteryConfig} label={label} status={renderedStatus} />
</div>
);
}
function BatteryBar({ charge, capacity, config }) {
function BatteryBar({ charge, capacity, config, label, status }) {
if (charge == null || !config?.full || config.warn == null) {
return null;
return (
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{label}</span>
<span>{status}</span>
</div>
);
}
const span = config.full - config.warn;
if (span <= 0) return null;
@@ -160,23 +158,20 @@ function BatteryBar({ charge, capacity, config }) {
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 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">
<div className="space-y-1 rounded-sm bg-[#111] p-1 text-xs text-slate-200">
<div className="flex items-center justify-between text-[0.65rem] text-slate-400">
<span>{label}</span>
<span>{status}</span>
</div>
<div className="flex items-center justify-between text-[0.7rem]">
<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 className="h-2 w-full rounded-full bg-slate-800">
<div className={`h-full rounded-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
</div>
</div>
);
@@ -199,20 +194,16 @@ function HudOverlay({ frame }) {
{ label: 'B-R', active: bumps.bumpRight },
];
const wheelBadges = [
{ label: 'DROP L', active: bumps.wheelDropLeft },
{ label: 'DROP R', active: bumps.wheelDropRight },
{ 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 className="pointer-events-none absolute inset-0 flex flex-col justify-between p-1 text-[0.65rem] text-slate-200">
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1 text-[0.55rem] text-slate-400">
<span className={`h-2 w-2 rounded-full ${pulse ? 'bg-emerald-300 shadow-[0_0_4px_rgba(16,185,129,0.8)]' : 'bg-slate-700'}`} />
<span>sensor</span>
</div>
<div className="flex gap-1">
{bumperBadges.map((badge) => (
@@ -232,11 +223,7 @@ function HudOverlay({ frame }) {
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'
}`}
className={`rounded-sm px-1 py-0.5 text-[0.55rem] ${active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'}`}
>
{label}
</span>
@@ -247,9 +234,7 @@ 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 className="text-center text-sm font-semibold text-red-100 animate-pulse">Overcurrent</div>
</div>
);
}
+8 -6
View File
@@ -4,13 +4,13 @@ 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('|');
const normalizedKey = Array.isArray(roverIds) ? roverIds.filter(Boolean).join('|') : '';
useEffect(() => {
if (!ids.length) {
if (!normalizedKey) {
return undefined;
}
const ids = normalizedKey.split('|');
let cancelled = false;
ids.forEach((roverId) => {
socket.emit('video:request', { roverId }, (resp = {}) => {
@@ -25,9 +25,11 @@ export function useVideoRequests(roverIds = []) {
return () => {
cancelled = true;
};
}, [socket, idsKey, ids]);
}, [socket, normalizedKey]);
const filtered = useMemo(() => {
if (!ids.length) return {};
if (!normalizedKey) return {};
const ids = normalizedKey.split('|');
const next = {};
ids.forEach((id) => {
if (sources[id]) {
@@ -35,7 +37,7 @@ export function useVideoRequests(roverIds = []) {
}
});
return next;
}, [ids, sources]);
}, [normalizedKey, sources]);
return filtered;
}