This commit is contained in:
legop3
2025-11-15 13:15:30 -05:00
parent c9d5b4e0f3
commit 0de8fb9633
7 changed files with 231 additions and 14 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
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-D9Hjnx7-.js"></script> <script type="module" crossorigin src="/assets/index-BrexHw5K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-dfoT05bK.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cyy-WeOF.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useSession } from './context/SessionContext.jsx'; import { useSession } from './context/SessionContext.jsx';
import TelemetryPanel from './components/TelemetryPanel.jsx'; import TelemetryPanel from './components/TelemetryPanel.jsx';
import VideoTile from './components/VideoTile.jsx'; import VideoTile from './components/VideoTile.jsx';
import DrivePanel from './components/DrivePanel.jsx';
import { useVideoRequests } from './hooks/useVideoRequests.js'; import { useVideoRequests } from './hooks/useVideoRequests.js';
function StatusBadge({ connected, role, mode }) { function StatusBadge({ connected, role, mode }) {
@@ -263,6 +264,7 @@ function App() {
<section className="grid gap-6 lg:grid-cols-2"> <section className="grid gap-6 lg:grid-cols-2">
<RosterPanel /> <RosterPanel />
<AssignmentCard /> <AssignmentCard />
<DrivePanel />
<TelemetryPanel /> <TelemetryPanel />
<AuthPanel /> <AuthPanel />
<SessionInspector /> <SessionInspector />
+63
View File
@@ -0,0 +1,63 @@
import { useDriveControls } from '../hooks/useDriveControls.js';
function SpeedMeter({ left, right }) {
return (
<div className="grid grid-cols-2 gap-4 rounded-2xl border border-slate-800/60 bg-slate-950/40 p-4 text-sm">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Left</p>
<p className="text-2xl font-semibold text-white">{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>
</div>
</div>
);
}
export default function DrivePanel() {
const { roverId, speeds, sensorEnabled, toggleSensorStream, stopMotors } = useDriveControls();
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>
<span>Space: browser handles scroll</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>
<button
type="button"
onClick={toggleSensorStream}
className="rounded-lg border border-slate-700 px-4 py-2 text-sm font-semibold text-slate-200 disabled:opacity-50"
disabled={!roverId}
>
{sensorEnabled ? 'Disable Sensor Stream' : 'Enable Sensor Stream'}
</button>
</div>
<p className="mt-4 text-xs text-slate-500">
Keep this tab focused to drive. Keyboard input is ignored while typing in form fields.
</p>
</section>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function computeSpeeds(keys) {
const forward = keys.has('w');
const backward = keys.has('s');
const left = keys.has('a');
const right = keys.has('d');
const fast = keys.has('shift');
const base = fast ? 300 : 150;
let leftSpeed = 0;
let rightSpeed = 0;
if (forward && !backward) {
leftSpeed += base;
rightSpeed += base;
} else if (backward && !forward) {
leftSpeed -= base;
rightSpeed -= base;
}
if (left && !right) {
leftSpeed -= base;
rightSpeed += base;
} else if (right && !left) {
leftSpeed += base;
rightSpeed -= base;
}
if (!forward && !backward && (left || right)) {
leftSpeed = left ? -base : base;
rightSpeed = left ? base : -base;
}
return {
left: clamp(leftSpeed, -500, 500),
right: clamp(rightSpeed, -500, 500),
};
}
function shouldIgnoreEvent(event) {
const target = event.target;
if (!target) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
target.isContentEditable ||
(tag === 'SELECT' && ['w', 'a', 's', 'd', 'Shift'].includes(event.key))
);
}
export function useDriveControls() {
const socket = useSocket();
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const keysRef = useRef(new Set());
const lastSpeedsRef = useRef({ left: 0, right: 0 });
const [currentSpeeds, setCurrentSpeeds] = useState(lastSpeedsRef.current);
const [sensorEnabled, setSensorEnabled] = useState(false);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
},
[socket, roverId],
);
const sendDriveUpdate = useCallback(() => {
if (!roverId) return;
const speeds = computeSpeeds(keysRef.current);
const last = lastSpeedsRef.current;
if (speeds.left === last.left && speeds.right === last.right) return;
lastSpeedsRef.current = speeds;
setCurrentSpeeds(speeds);
emitCommand({
type: 'drive',
data: { driveDirect: speeds },
});
}, [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]);
const toggleSensorStream = useCallback(() => {
if (!roverId) return;
setSensorEnabled((prev) => {
const next = !prev;
emitCommand({
type: 'sensorStream',
data: { sensorStream: { enable: next } },
});
return next;
});
}, [emitCommand, roverId]);
useEffect(() => {
if (!roverId) {
keysRef.current.clear();
lastSpeedsRef.current = { left: 0, right: 0 };
setCurrentSpeeds(lastSpeedsRef.current);
setSensorEnabled(false);
return undefined;
}
function onKeyDown(event) {
if (shouldIgnoreEvent(event)) return;
const key = event.key.toLowerCase();
if (!['w', 'a', 's', 'd', 'shift'].includes(key)) return;
if (keysRef.current.has(key)) return;
keysRef.current.add(key);
sendDriveUpdate();
}
function onKeyUp(event) {
const key = event.key.toLowerCase();
if (!keysRef.current.has(key)) return;
keysRef.current.delete(key);
sendDriveUpdate();
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [roverId, sendDriveUpdate]);
return {
roverId,
speeds: currentSpeeds,
sensorEnabled,
toggleSensorStream,
stopMotors,
};
}