This commit is contained in:
legop3
2025-11-17 21:18:43 -05:00
parent 2303fd3c7b
commit dc6a4b3c3a
22 changed files with 1318 additions and 493 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-DwtMH0qf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dk59njim.css">
<script type="module" crossorigin src="/assets/index-De1-bHUg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BMozRdoG.css">
</head>
<body>
<div id="root"></div>
+11 -10
View File
@@ -3,8 +3,11 @@ import TelemetryPanel from './components/TelemetryPanel.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 MobileControls, {
MobileLandscapeAuxColumn,
MobileLandscapeControlColumn,
} from './components/MobileControls.jsx';
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx';
import AuthPanel from './components/AuthPanel.jsx';
@@ -78,13 +81,9 @@ function MobileLandscapeLayout() {
return (
<div className="flex flex-col gap-1">
<section className="grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
<div className="flex flex-col gap-1">
<AuxMotorControls />
</div>
<MobileLandscapeAuxColumn />
<DriverVideoPanel />
<div className="flex flex-col gap-1">
<MobileJoystick />
</div>
<MobileLandscapeControlColumn />
</section>
<RoomCameraPanel />
<DrivePanel />
@@ -113,11 +112,13 @@ function App() {
return (
<div className="min-h-screen bg-black text-slate-50">
<DriveControlProvider>
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<main className="flex w-full flex-col gap-1 px-1 py-1 text-base">{renderedLayout}</main>
<AlertFeed />
<ModeGateOverlay />
</DriveControlProvider>
</ControlSystemProvider>
</div>
);
}
+6 -9
View File
@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { useControlSystem } from '../controls/index.js';
import AuthPanel from './AuthPanel.jsx';
import AdminPanel from './AdminPanel.jsx';
@@ -13,8 +12,10 @@ const manualTabs = [
];
export default function AdvancedSettings() {
const { roverId, sendOiCommand } = useDriveControl();
const socket = useSocket();
const {
state: { roverId },
actions: { sendOiCommand, setSensorStream },
} = useControlSystem();
const canControl = Boolean(roverId);
const sensorButtons = useMemo(
@@ -27,11 +28,7 @@ export default function AdvancedSettings() {
const handleSensorToggle = (enable) => {
if (!roverId) return;
socket.emit('command', {
roverId,
type: 'sensorStream',
data: { sensorStream: { enable } },
});
setSensorStream(enable);
};
return (
+13 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useControlSystem } from '../controls/index.js';
const SLIDER_THROTTLE_MS = 150;
@@ -9,14 +9,17 @@ function formatDegrees(value) {
}
export default function CameraServoPanel() {
const { roverId, servo } = useDriveControl();
const config = servo?.config;
const enabled = Boolean(roverId && servo?.enabled && config);
const {
state: { roverId, camera },
actions: { setServoAngle, nudgeServo, goServoHome },
} = useControlSystem();
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
typeof servo?.angle === 'number'
? servo.angle
typeof camera?.angle === 'number'
? camera.angle
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
@@ -52,7 +55,7 @@ export default function CameraServoPanel() {
clearTimeout(throttleRef.current);
}
throttleRef.current = setTimeout(() => {
servo.setAngle(next);
setServoAngle(next);
}, SLIDER_THROTTLE_MS);
};
@@ -69,12 +72,12 @@ export default function CameraServoPanel() {
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
servo.setAngle(pendingAngle);
setServoAngle(pendingAngle);
};
const handleNudge = (direction) => {
const delta = step * direction;
servo.nudge(delta);
nudgeServo(delta);
};
return (
@@ -112,7 +115,7 @@ export default function CameraServoPanel() {
<button
type="button"
className="flex-1 rounded bg-slate-700 px-1 py-0.5 text-slate-100 hover:bg-slate-600"
onClick={() => servo.goHome()}
onClick={() => goServoHome()}
>
Center
</button>
+19 -4
View File
@@ -1,8 +1,11 @@
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useControlSystem } from '../controls/index.js';
export default function DrivePanel() {
const { roverId, runStartDockFull, seekDock } = useDriveControl();
const {
state: { roverId },
actions,
} = useControlSystem();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
@@ -11,6 +14,18 @@ export default function DrivePanel() {
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
);
const handleStartDrive = () => {
if (!roverId) return;
actions.setMode('drive');
actions.runMacro('drive-sequence');
};
const handleDock = () => {
if (!roverId) return;
actions.setMode('dock');
actions.runMacro('seek-dock');
};
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="flex items-center justify-between text-sm text-slate-300">
@@ -23,7 +38,7 @@ export default function DrivePanel() {
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]}
tone="emerald"
onClick={runStartDockFull}
onClick={handleStartDrive}
disabled={!roverId}
/>
<ActionCard
@@ -34,7 +49,7 @@ export default function DrivePanel() {
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
]}
tone="indigo"
onClick={seekDock}
onClick={handleDock}
disabled={!roverId}
/>
</div>
+117 -87
View File
@@ -1,114 +1,132 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { Joystick } from 'react-joystick-component';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useControlSystem } from '../controls/index.js';
import { clampUnit } from '../controls/controlMath.js';
import DriveModeToggle from './controls/DriveModeToggle.jsx';
function clampUnit(value = 0) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
const SOURCE = 'mobile-joystick';
const AUX_BUTTONS = [
{ id: 'main-forward', label: 'Main +', values: { main: 127 }, hold: true },
{ id: 'main-reverse', label: 'Main -', values: { main: -127 }, hold: true },
{ id: 'side-forward', label: 'Side +', values: { side: 127 }, hold: true },
{ id: 'side-reverse', label: 'Side -', values: { side: -70 }, hold: true },
{ id: 'vacuum-fast', label: 'Vacuum Max', values: { vacuum: 127 }, hold: true },
{ id: 'vacuum-slow', label: 'Vacuum Low', values: { vacuum: 50 }, hold: true },
{ id: 'all-forward', label: 'All +', values: { main: 127, side: 127, vacuum: 127 }, hold: true },
{ id: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false },
];
function MobileJoystick() {
const { roverId, driveWithVector, stopMotors } = useDriveControl();
function MobileJoystickPanel({ layout }) {
const {
state: { roverId },
actions: { setDriveVector, registerInputState, stopAllMotion },
} = useControlSystem();
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 });
const vector = {
x: clampUnit(event.x ?? 0),
y: clampUnit(event.y ?? 0),
boost: Boolean(event.shiftKey),
};
setDriveVector(vector, { source: SOURCE });
registerInputState(SOURCE, { vector, lastEvent: 'move' });
},
[disabled, driveWithVector],
[disabled, registerInputState, setDriveVector],
);
const handleStop = useCallback(() => {
if (disabled) return;
driveWithVector({ x: 0, y: 0 });
}, [disabled, driveWithVector]);
const zero = { x: 0, y: 0, boost: false };
setDriveVector(zero, { source: SOURCE });
registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' });
}, [disabled, registerInputState, setDriveVector]);
return (
<section className="rounded-sm bg-[#242a32] 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"
stickColor="#38bdf8"
throttle={75}
move={handleMove}
stop={handleStop}
<DriveModeToggle size="compact" />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center justify-center">
<Joystick
size={layout === 'landscape' ? 140 : 120}
baseColor="#0f172a"
stickColor="#38bdf8"
throttle={60}
move={handleMove}
stop={handleStop}
disabled={disabled}
/>
</div>
<p className="text-[0.7rem] text-slate-400">Drag to steer · Release to stop sending drive commands.</p>
<button
type="button"
onClick={stopAllMotion}
disabled={disabled}
/>
className="rounded-sm bg-red-600 px-1 py-1 text-xs font-semibold uppercase tracking-wide text-red-100 disabled:opacity-40"
>
Panic Stop
</button>
</div>
<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-1 w-full rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
Panic Stop
</button>
</section>
);
}
function AuxMotorControls() {
const { roverId, setAuxMotors } = useDriveControl();
function AuxMotorPanel({ orientation }) {
const {
state: { roverId },
actions: { setAuxMotors },
} = useControlSystem();
const activeRef = useRef(null);
const disabled = !roverId;
const runAllForward = () => {
if (disabled) return;
setAuxMotors({ main: 127, side: 127, vacuum: 127 });
};
const handlePress = useCallback(
(button) => {
if (disabled) return;
if (button.hold === false) {
setAuxMotors(button.values);
activeRef.current = null;
return;
}
activeRef.current = button.id;
setAuxMotors(button.values);
},
[disabled, setAuxMotors],
);
const stopAll = () => {
if (disabled) return;
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
};
const handleRelease = useCallback(
(button) => {
if (disabled || button.hold === false) return;
if (activeRef.current === button.id) {
activeRef.current = null;
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
}
},
[disabled, setAuxMotors],
);
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 } },
];
const buttonClasses = orientation === 'landscape' ? 'text-xs' : 'text-[0.75rem]';
return (
<section className="rounded-sm bg-[#242a32] 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-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
All forward
</button>
<button
type="button"
onClick={stopAll}
disabled={disabled}
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-1 grid grid-cols-2 gap-1 text-[0.75rem]">
{auxButtons.map((btn) => (
<p className="text-xs text-slate-400">Aux motors · hold to run</p>
<div className="mt-1 grid grid-cols-2 gap-1">
{AUX_BUTTONS.map((button) => (
<button
key={btn.label}
key={button.id}
type="button"
onClick={() => !disabled && setAuxMotors(btn.values)}
disabled={disabled}
className="rounded-sm bg-black/30 px-1 py-1 text-slate-200 disabled:opacity-30"
onPointerDown={(event) => {
event.preventDefault();
handlePress(button);
}}
onPointerUp={() => handleRelease(button)}
onPointerLeave={() => handleRelease(button)}
onPointerCancel={() => handleRelease(button)}
className={`rounded-sm bg-black/40 px-1 py-1 text-left text-slate-100 disabled:opacity-30 ${buttonClasses}`}
>
{btn.label}
{button.label}
</button>
))}
</div>
@@ -116,17 +134,29 @@ function AuxMotorControls() {
);
}
export default function MobileControlsStack() {
export function MobileLandscapeAuxColumn() {
return (
<div className="flex flex-col gap-1 sm:flex-row">
<div className="flex-1">
<MobileJoystick />
</div>
<div className="flex-1">
<AuxMotorControls />
</div>
<div className="flex flex-col gap-1">
<AuxMotorPanel orientation="landscape" />
</div>
);
}
export { MobileJoystick, AuxMotorControls };
export function MobileLandscapeControlColumn() {
return (
<div className="flex flex-col gap-1">
<MobileJoystickPanel layout="landscape" />
</div>
);
}
export default function MobilePortraitControls() {
return (
<section className="rounded-sm bg-transparent">
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
<AuxMotorPanel orientation="portrait" />
<MobileJoystickPanel layout="portrait" />
</div>
</section>
);
}
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { useControlSystem } from '../../controls/index.js';
export default function DriveModeToggle({ size = 'default' }) {
const {
state: { roverId, mode },
actions,
} = useControlSystem();
const [pending, setPending] = useState(null);
const disabled = !roverId || pending !== null;
const handleDrive = async () => {
if (!roverId) return;
setPending('drive');
try {
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} finally {
setPending(null);
}
};
const handleDock = async () => {
if (!roverId) return;
setPending('dock');
try {
actions.setMode('dock');
await actions.runMacro('seek-dock');
} finally {
setPending(null);
}
};
const pillClass =
size === 'compact'
? 'text-xs px-1 py-0.5'
: 'text-sm px-1.5 py-0.5';
const currentLabel = mode === 'dock' ? 'Dock mode' : 'Drive mode';
return (
<div className="rounded-sm bg-black/30 p-1 text-slate-100">
<div className="flex items-center justify-between">
<span className={`rounded-full bg-slate-800 ${pillClass}`}>{currentLabel}</span>
<span className="text-[0.65rem] text-slate-400">Rover {roverId ?? '—'}</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-1 text-xs">
<button
type="button"
onClick={handleDrive}
disabled={disabled}
className={`rounded-sm bg-emerald-600 px-1 py-1 font-semibold uppercase tracking-wide text-emerald-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
>
Drive
</button>
<button
type="button"
onClick={handleDock}
disabled={disabled}
className={`rounded-sm bg-indigo-600 px-1 py-1 font-semibold uppercase tracking-wide text-indigo-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
>
Dock
</button>
</div>
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
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;
}
+235
View File
@@ -0,0 +1,235 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer } from 'react';
import { controlReducer, initialControlState } from './controlReducer.js';
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
import { useCommandPipeline } from './commandPipeline.js';
import { loadControlSettings, saveControlSettings } from './persistence.js';
const ControlSystemContext = createContext(null);
function clampServoAngle(config, value) {
if (!config) return value;
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
return clamp(value, min, max);
}
export function ControlSystemProvider({ children }) {
const pipeline = useCommandPipeline();
const [state, dispatch] = useReducer(controlReducer, initialControlState);
useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
}, [pipeline.roverId]);
useEffect(() => {
dispatch({ type: 'control/settings/loading' });
const data = loadControlSettings();
dispatch({ type: 'control/settings/loaded', payload: data });
}, []);
useEffect(() => {
const config = pipeline.servoConfig;
if (!config) {
dispatch({ type: 'control/set-camera-config', payload: { config: null } });
return;
}
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
const base = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2;
dispatch({
type: 'control/set-camera-config',
payload: { config, angle: clamp(base, min, max) },
});
}, [pipeline.servoConfig]);
useEffect(() => {
if (pipeline.roverId) {
pipeline.enableSensorStream();
}
}, [pipeline.roverId, pipeline.enableSensorStream]);
const setMode = useCallback(
(mode) => {
dispatch({ type: 'control/set-mode', payload: mode });
},
[],
);
const setDriveVector = useCallback(
(vector, meta = {}) => {
const computed = computeDifferentialSpeeds(vector);
dispatch({
type: 'control/update-drive',
payload: { ...computed, source: meta.source ?? null },
});
pipeline.sendDriveDirect(computed.speeds);
},
[pipeline],
);
const setAuxMotors = useCallback(
(values = {}) => {
const payload = pipeline.sendAuxMotors(values) ?? values;
dispatch({
type: 'control/set-aux-motors',
payload,
});
},
[pipeline],
);
const setServoAngle = useCallback(
(value) => {
if (!pipeline.servoConfig) return;
const clamped = clampServoAngle(pipeline.servoConfig, value);
dispatch({ type: 'control/set-camera-angle', payload: clamped });
pipeline.sendServoAngle(clamped);
},
[pipeline],
);
const nudgeServo = useCallback(
(delta = 0) => {
const config = pipeline.servoConfig;
if (!config) return;
const step = typeof delta === 'number' && delta !== 0 ? delta : config.nudgeDegrees || 1;
const baseline =
typeof state.camera.angle === 'number'
? state.camera.angle
: typeof config.homeAngle === 'number'
? config.homeAngle
: 0;
setServoAngle(baseline + step);
},
[pipeline.servoConfig, setServoAngle, state.camera.angle],
);
const goServoHome = useCallback(() => {
const config = pipeline.servoConfig;
if (!config) return;
const target =
typeof config.homeAngle === 'number'
? config.homeAngle
: typeof config.minAngle === 'number' && typeof config.maxAngle === 'number'
? (config.minAngle + config.maxAngle) / 2
: 0;
setServoAngle(target);
}, [pipeline.servoConfig, setServoAngle]);
const runMacro = useCallback(
async (macroId) => {
const macro = state.macros.find((item) => item.id === macroId) || null;
if (!macro) return;
await pipeline.runMacroSteps(macro);
},
[pipeline, state.macros],
);
const stopAllMotion = useCallback(() => {
dispatch({
type: 'control/update-drive',
payload: {
vector: { x: 0, y: 0, boost: false },
speeds: { left: 0, right: 0 },
source: 'system-stop',
},
});
pipeline.sendDriveDirect({ left: 0, right: 0 });
pipeline.sendAuxMotors({ main: 0, side: 0, vacuum: 0 });
}, [pipeline]);
const sendOiCommand = useCallback(
(command) => {
pipeline.sendOiCommand(command);
},
[pipeline],
);
const setSensorStream = useCallback(
(enable) => {
if (!pipeline.roverId) return;
pipeline.emitCommand({
type: 'sensorStream',
data: { sensorStream: { enable } },
});
},
[pipeline],
);
const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []);
const reloadSettings = useCallback(() => {
dispatch({ type: 'control/settings/loading' });
const next = loadControlSettings();
dispatch({ type: 'control/settings/loaded', payload: next });
}, []);
const persistSettings = useCallback(
(partial) => {
const merged = { ...(state.settings.data ?? {}), ...(partial ?? {}) };
const success = saveControlSettings(merged);
if (success) {
dispatch({ type: 'control/settings/loaded', payload: merged });
} else {
dispatch({
type: 'control/settings/error',
payload: new Error('Failed to save control settings'),
});
}
return success;
},
[state.settings.data],
);
const contextValue = useMemo(
() => ({
state,
dispatch,
pipeline,
actions: {
setMode,
setDriveVector,
setAuxMotors,
setServoAngle,
nudgeServo,
goServoHome,
runMacro,
stopAllMotion,
sendOiCommand,
setSensorStream,
registerInputState,
reloadSettings,
persistSettings,
},
}),
[
state,
pipeline,
setMode,
setDriveVector,
setAuxMotors,
setServoAngle,
nudgeServo,
goServoHome,
runMacro,
stopAllMotion,
sendOiCommand,
setSensorStream,
registerInputState,
reloadSettings,
persistSettings,
],
);
return <ControlSystemContext.Provider value={contextValue}>{children}</ControlSystemContext.Provider>;
}
export function useControlSystem() {
const context = useContext(ControlSystemContext);
if (!context) {
throw new Error('useControlSystem must be used within ControlSystemProvider');
}
return context;
}
+163
View File
@@ -0,0 +1,163 @@
import { useCallback, useMemo } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { AUX_LIMITS, COMMAND_DELAY_MS, OI_COMMANDS } from './constants.js';
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline() {
const socket = useSocket();
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const rosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(session?.roster)) return null;
return session.roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, session?.roster]);
const servoConfig = useMemo(() => {
if (!rosterEntry?.cameraServo || !rosterEntry.cameraServo.enabled) return null;
return rosterEntry.cameraServo;
}, [rosterEntry]);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
},
[socket, roverId],
);
const enableSensorStream = useCallback(() => {
if (!roverId) return;
emitCommand({
type: 'sensorStream',
data: { sensorStream: { enable: true } },
});
}, [emitCommand, roverId]);
const sendDriveDirect = useCallback(
(speeds) => {
if (!roverId) return null;
const payload = {
left: clampRange(speeds?.left ?? 0, [-500, 500]),
right: clampRange(speeds?.right ?? 0, [-500, 500]),
};
emitCommand({
type: 'drive',
data: { driveDirect: payload },
});
return payload;
},
[emitCommand, roverId],
);
const sendAuxMotors = useCallback(
({ main = 0, side = 0, vacuum = 0 } = {}) => {
if (!roverId) return null;
const payload = {
main: clampRange(main, AUX_LIMITS.main),
side: clampRange(side, AUX_LIMITS.side),
vacuum: clampRange(vacuum, AUX_LIMITS.vacuum),
};
emitCommand({
type: 'motors',
data: { motorPwm: payload },
});
return payload;
},
[emitCommand, roverId],
);
const sendServoAngle = useCallback(
(angle) => {
if (!roverId || !servoConfig) return null;
emitCommand({
type: 'servo',
data: { servo: { angle } },
});
return angle;
},
[emitCommand, roverId, servoConfig],
);
const sendOiCommand = useCallback(
(keyOrBytes) => {
if (!roverId) return false;
const bytes = Array.isArray(keyOrBytes)
? keyOrBytes
: typeof keyOrBytes === 'string'
? OI_COMMANDS[keyOrBytes]
: null;
if (!bytes) return false;
emitCommand({
type: 'raw',
data: { raw: bytesToBase64(bytes) },
});
enableSensorStream();
return true;
},
[emitCommand, enableSensorStream, roverId],
);
const runMacroSteps = useCallback(
async (macro) => {
if (!macro || !Array.isArray(macro.steps) || !roverId) return;
for (const step of macro.steps) {
if (!roverId) break;
switch (step.type) {
case 'oi':
sendOiCommand(step.command);
break;
case 'drive':
sendDriveDirect(step.speeds ?? { left: 0, right: 0 });
break;
case 'motors':
sendAuxMotors(step.values ?? {});
break;
case 'servo':
sendServoAngle(step.angle);
break;
case 'pause':
await sleep(step.duration ?? COMMAND_DELAY_MS); // eslint-disable-line no-await-in-loop
break;
default:
break;
}
if (step.delay || step.delayMs) {
const delay = step.delayMs ?? step.delay;
if (typeof delay === 'number' && delay > 0) {
await sleep(delay); // eslint-disable-line no-await-in-loop
}
}
}
},
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle],
);
return useMemo(
() => ({
roverId,
rosterEntry,
servoConfig,
emitCommand,
enableSensorStream,
sendDriveDirect,
sendAuxMotors,
sendServoAngle,
sendOiCommand,
runMacroSteps,
}),
[
roverId,
rosterEntry,
servoConfig,
emitCommand,
enableSensorStream,
sendDriveDirect,
sendAuxMotors,
sendServoAngle,
sendOiCommand,
runMacroSteps,
],
);
}
+65
View File
@@ -0,0 +1,65 @@
export const AUX_LIMITS = {
main: [-127, 127],
side: [-127, 127],
vacuum: [0, 127],
};
export const DRIVE_LIMITS = {
maxSpeed: 500,
baseSpeed: 250,
boostSpeed: 400,
};
export const COMMAND_DELAY_MS = 200;
export const OI_COMMANDS = {
start: [128],
safe: [131],
full: [132],
passive: [128],
dock: [143],
};
export const DEFAULT_KEYMAP = {
driveForward: ['w'],
driveBackward: ['s'],
driveLeft: ['a'],
driveRight: ['d'],
boostModifier: ['\\\\'],
slowModifier: ['shift'],
auxMainForward: ['o'],
auxMainReverse: ['l'],
auxSideForward: ['p'],
auxSideReverse: [';'],
auxVacuumFast: ['['],
auxVacuumSlow: ["'"],
auxAllForward: ['.'],
cameraUp: ['i'],
cameraDown: ['k'],
driveMacro: ['g'],
dockMacro: ['h'],
};
export const DEFAULT_MACROS = [
{
id: 'drive-sequence',
label: 'Drive',
description: 'Start, dock, and full command sequence used by the drive button.',
steps: [
{ type: 'oi', command: 'start' },
{ type: 'pause', duration: COMMAND_DELAY_MS },
{ type: 'oi', command: 'dock' },
{ type: 'pause', duration: COMMAND_DELAY_MS },
{ type: 'oi', command: 'full' },
],
},
{
id: 'seek-dock',
label: 'Dock',
description: 'Send the seek dock command.',
steps: [{ type: 'oi', command: 'dock' }],
},
];
export const CONTROL_SETTINGS_COOKIE = 'roverControlSettings';
export const CONTROL_SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
+59
View File
@@ -0,0 +1,59 @@
/* global Buffer */
import { DRIVE_LIMITS } from './constants.js';
export function clamp(value, min, max) {
if (typeof value !== 'number' || Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
}
export function clampUnit(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
export function clampRange(value, range) {
if (!Array.isArray(range) || range.length !== 2) return value;
return clamp(value, range[0], range[1]);
}
export function normalizeDriveVector(vector = {}) {
const x = clampUnit(vector.x ?? 0);
const y = clampUnit(vector.y ?? 0);
return {
x,
y,
boost: Boolean(vector.boost),
};
}
export function computeDifferentialSpeeds(vector = {}, options = {}) {
const normalized = normalizeDriveVector(vector);
const maxSpeed = typeof options.maxSpeed === 'number' ? options.maxSpeed : DRIVE_LIMITS.maxSpeed;
const baseSpeed = typeof options.baseSpeed === 'number' ? options.baseSpeed : DRIVE_LIMITS.baseSpeed;
const boostSpeed = typeof options.boostSpeed === 'number' ? options.boostSpeed : DRIVE_LIMITS.boostSpeed;
const base = normalized.boost ? boostSpeed : baseSpeed;
const forward = normalized.y * base;
const turn = normalized.x * base;
return {
vector: normalized,
speeds: {
left: clamp(Math.round(forward + turn), -maxSpeed, maxSpeed),
right: clamp(Math.round(forward - turn), -maxSpeed, maxSpeed),
},
};
}
export function bytesToBase64(bytes) {
const safeBytes = Array.isArray(bytes) ? bytes : [];
const binary = String.fromCharCode(...safeBytes);
if (typeof btoa === 'function') return btoa(binary);
if (typeof Buffer !== 'undefined') {
return Buffer.from(safeBytes).toString('base64');
}
throw new Error('No base64 encoder available');
}
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+166
View File
@@ -0,0 +1,166 @@
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
function createDriveState() {
return {
vector: { x: 0, y: 0, boost: false },
speeds: { left: 0, right: 0 },
source: null,
lastUpdatedAt: 0,
};
}
function createAuxState() {
return { main: 0, side: 0, vacuum: 0 };
}
function createCameraState() {
return {
enabled: false,
angle: null,
config: null,
};
}
export const initialControlState = {
roverId: null,
mode: 'drive',
drive: createDriveState(),
aux: createAuxState(),
camera: createCameraState(),
macros: DEFAULT_MACROS,
keymap: DEFAULT_KEYMAP,
inputs: {},
settings: {
status: 'idle',
error: null,
lastLoadedAt: null,
lastSavedAt: null,
data: null,
},
};
function mergeSettings(state, payload) {
const nextSettings = {
status: 'ready',
error: null,
lastLoadedAt: Date.now(),
lastSavedAt: state.settings.lastSavedAt,
data: payload ?? state.settings.data,
};
const nextState = { ...state, settings: nextSettings };
if (payload?.keymap) {
nextState.keymap = { ...state.keymap, ...payload.keymap };
}
if (payload?.macros) {
nextState.macros = payload.macros;
}
return nextState;
}
export function controlReducer(state, action) {
switch (action.type) {
case 'control/set-rover':
return {
...state,
roverId: action.payload ?? null,
drive: action.payload ? state.drive : createDriveState(),
aux: action.payload ? state.aux : createAuxState(),
};
case 'control/set-mode':
return state.mode === action.payload
? state
: { ...state, mode: action.payload === 'dock' ? 'dock' : 'drive' };
case 'control/update-drive': {
const { vector, speeds, source } = action.payload;
return {
...state,
drive: {
vector: vector ?? state.drive.vector,
speeds: speeds ?? state.drive.speeds,
source: source ?? state.drive.source,
lastUpdatedAt: Date.now(),
},
};
}
case 'control/set-aux-motors':
return {
...state,
aux: {
...state.aux,
...action.payload,
},
};
case 'control/set-camera-config':
return {
...state,
camera: {
...state.camera,
enabled: Boolean(action.payload?.config),
config: action.payload?.config ?? null,
angle:
typeof action.payload?.angle === 'number'
? action.payload.angle
: action.payload?.config
? action.payload.config.homeAngle ?? state.camera.angle
: null,
},
};
case 'control/set-camera-angle':
return {
...state,
camera: { ...state.camera, angle: action.payload },
};
case 'control/register-input-state': {
const sourceKey = action.payload?.source || 'unknown';
return {
...state,
inputs: {
...state.inputs,
[sourceKey]: {
...(state.inputs[sourceKey] ?? {}),
...action.payload?.state,
updatedAt: Date.now(),
},
},
};
}
case 'control/settings/loading':
return {
...state,
settings: {
...state.settings,
status: 'loading',
error: null,
},
};
case 'control/settings/loaded':
return mergeSettings(state, action.payload);
case 'control/settings/error':
return {
...state,
settings: {
...state.settings,
status: 'error',
error: action.payload,
},
};
case 'control/set-keymap':
return {
...state,
keymap: { ...state.keymap, ...(action.payload ?? {}) },
};
case 'control/set-macros':
return {
...state,
macros: Array.isArray(action.payload) ? action.payload : state.macros,
};
case 'control/reset':
return {
...state,
drive: createDriveState(),
aux: createAuxState(),
};
default:
return state;
}
}
+3
View File
@@ -0,0 +1,3 @@
export { ControlSystemProvider, useControlSystem } from './ControlContext.jsx';
export { default as KeyboardInputManager } from './inputs/KeyboardInputManager.jsx';
export { default as GamepadInputManager } from './inputs/GamepadInputManager.jsx';
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef } from 'react';
import { useControlSystem } from '../ControlContext.jsx';
import { clampUnit } from '../controlMath.js';
const SOURCE = 'gamepad';
const DEADZONE = 0.2;
function applyDeadzone(value) {
return Math.abs(value) < DEADZONE ? 0 : value;
}
function computeTriggerSpeed(button, reverseButton, max = 127, reverseScale = 1) {
if (!button) return 0;
const value = typeof button.value === 'number' ? button.value : button.pressed ? 1 : 0;
if (value < 0.05) return 0;
const direction = reverseButton?.pressed ? -1 : 1;
return Math.round(value * max * direction * reverseScale);
}
function vectorsEqual(a, b) {
return (
a &&
b &&
a.x === b.x &&
a.y === b.y &&
a.boost === b.boost
);
}
function auxEqual(a, b) {
return a && b && a.main === b.main && a.side === b.side && a.vacuum === b.vacuum;
}
export default function GamepadInputManager() {
const {
actions: {
setMode,
setDriveVector,
setAuxMotors,
nudgeServo,
runMacro,
registerInputState,
},
} = useControlSystem();
const rafRef = useRef(null);
const lastVectorRef = useRef({ x: 0, y: 0, boost: false });
const lastAuxRef = useRef({ main: 0, side: 0, vacuum: 0 });
const buttonStateRef = useRef(new Map());
const servoThrottleRef = useRef(0);
const handleButtonEdge = useCallback(
(index, pressed) => {
const prev = buttonStateRef.current.get(index) || false;
buttonStateRef.current.set(index, pressed);
return pressed && !prev;
},
[],
);
const pollGamepads = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
return;
}
const pads = navigator.getGamepads();
const pad = pads && Array.from(pads).find(Boolean);
if (!pad) {
if (!vectorsEqual(lastVectorRef.current, { x: 0, y: 0, boost: false })) {
lastVectorRef.current = { x: 0, y: 0, boost: false };
setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE });
}
if (!auxEqual(lastAuxRef.current, { main: 0, side: 0, vacuum: 0 })) {
lastAuxRef.current = { main: 0, side: 0, vacuum: 0 };
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
}
registerInputState(SOURCE, { connected: false });
return;
}
const axisLX = clampUnit(applyDeadzone(pad.axes?.[0] ?? 0));
const axisLY = clampUnit(applyDeadzone(-(pad.axes?.[1] ?? 0)));
const vector = { x: axisLX, y: axisLY, boost: false };
if (!vectorsEqual(vector, lastVectorRef.current)) {
lastVectorRef.current = vector;
setDriveVector(vector, { source: SOURCE });
}
const main = computeTriggerSpeed(pad.buttons?.[7], pad.buttons?.[5]);
const side = computeTriggerSpeed(pad.buttons?.[6], pad.buttons?.[4], 127, 0.55);
const vacuum = pad.buttons?.[1]?.pressed ? 127 : 0;
let aux = { main, side, vacuum };
if (pad.buttons?.[0]?.pressed) {
aux = { main: 127, side: 127, vacuum: 127 };
}
if (!auxEqual(aux, lastAuxRef.current)) {
lastAuxRef.current = aux;
setAuxMotors(aux);
}
const cameraAxis = clampUnit(applyDeadzone(-(pad.axes?.[3] ?? 0)));
const now = performance.now();
if (Math.abs(cameraAxis) > 0.25 && now - servoThrottleRef.current > 120) {
servoThrottleRef.current = now;
nudgeServo(cameraAxis > 0 ? 2 : -2);
}
if (handleButtonEdge(14, pad.buttons?.[14]?.pressed)) {
setMode('drive');
runMacro('drive-sequence');
}
if (handleButtonEdge(15, pad.buttons?.[15]?.pressed)) {
setMode('dock');
runMacro('seek-dock');
}
registerInputState(SOURCE, {
connected: true,
id: pad.id,
index: pad.index,
axes: [axisLX, axisLY, cameraAxis],
buttons: {
a: pad.buttons?.[0]?.pressed || false,
b: pad.buttons?.[1]?.pressed || false,
lb: pad.buttons?.[4]?.pressed || false,
rb: pad.buttons?.[5]?.pressed || false,
},
});
}, [handleButtonEdge, nudgeServo, registerInputState, runMacro, setAuxMotors, setDriveVector, setMode]);
useEffect(() => {
function loop() {
pollGamepads();
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [pollGamepads]);
return null;
}
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useControlSystem } from '../ControlContext.jsx';
const SOURCE = 'keyboard';
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
function shouldIgnoreEvent(event) {
const target = event.target;
if (!target) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
target.isContentEditable ||
tag === 'SELECT'
);
}
function normalizeKeymap(keymap = {}) {
const entries = Object.entries(keymap).map(([action, bindings]) => {
const values = Array.isArray(bindings) ? bindings : [bindings];
const normalized = new Set(values.map((value) => String(value).toLowerCase()));
return [action, normalized];
});
return Object.fromEntries(entries);
}
function bindingHas(bindingSet, key) {
if (!bindingSet || bindingSet.size === 0) return false;
return bindingSet.has(key);
}
function bindingActive(bindingSet, keys) {
if (!bindingSet || bindingSet.size === 0) return false;
for (const key of keys) {
if (bindingSet.has(key)) return true;
}
return false;
}
function computeDriveVector(keys, keymap) {
const forward = bindingActive(keymap.driveForward, keys);
const backward = bindingActive(keymap.driveBackward, keys);
const left = bindingActive(keymap.driveLeft, keys);
const right = bindingActive(keymap.driveRight, keys);
const boost = bindingActive(keymap.boostModifier, keys);
const slow = bindingActive(keymap.slowModifier, keys);
let y = 0;
if (forward && !backward) y = 1;
else if (backward && !forward) y = -1;
let x = 0;
if (left && !right) x = -1;
else if (right && !left) x = 1;
const scale = slow ? 0.4 : 1;
return {
x: x * scale,
y: y * scale,
boost: boost && !slow,
};
}
function computeAuxMotors(keys, keymap) {
const allForward = bindingActive(keymap.auxAllForward, keys);
if (allForward) {
return { main: 127, side: 127, vacuum: 127 };
}
const main = bindingActive(keymap.auxMainForward, keys)
? 127
: bindingActive(keymap.auxMainReverse, keys)
? -127
: 0;
const side = bindingActive(keymap.auxSideForward, keys)
? 127
: bindingActive(keymap.auxSideReverse, keys)
? -70
: 0;
const vacuum = bindingActive(keymap.auxVacuumFast, keys)
? 127
: bindingActive(keymap.auxVacuumSlow, keys)
? 50
: 0;
return { main, side, vacuum };
}
function vectorsEqual(a, b) {
return (
a &&
b &&
a.x === b.x &&
a.y === b.y &&
a.boost === b.boost
);
}
function auxEqual(a, b) {
return a && b && a.main === b.main && a.side === b.side && a.vacuum === b.vacuum;
}
export default function KeyboardInputManager() {
const {
state,
actions: {
setMode,
setDriveVector,
setAuxMotors,
nudgeServo,
runMacro,
stopAllMotion,
registerInputState,
},
} = useControlSystem();
const keymap = useMemo(() => normalizeKeymap(state.keymap), [state.keymap]);
const servoStep = Math.abs(state.camera?.config?.nudgeDegrees) || 1;
const pressedKeysRef = useRef(new Set());
const lastVectorRef = useRef(ZERO_VECTOR);
const lastAuxRef = useRef(ZERO_AUX);
const updateFromKeys = useCallback(() => {
const keys = pressedKeysRef.current;
const vector = computeDriveVector(keys, keymap);
const aux = computeAuxMotors(keys, keymap);
if (!vectorsEqual(vector, lastVectorRef.current)) {
lastVectorRef.current = vector;
setDriveVector(vector, { source: SOURCE });
}
if (!auxEqual(aux, lastAuxRef.current)) {
lastAuxRef.current = aux;
setAuxMotors(aux);
}
registerInputState(SOURCE, {
keys: Array.from(keys),
vector,
aux,
});
}, [keymap, registerInputState, setAuxMotors, setDriveVector]);
const resetAll = useCallback(() => {
pressedKeysRef.current.clear();
lastVectorRef.current = ZERO_VECTOR;
lastAuxRef.current = ZERO_AUX;
stopAllMotion();
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
}, [registerInputState, stopAllMotion]);
useEffect(() => {
function handleKeyDown(event) {
if (shouldIgnoreEvent(event)) return;
const key = event.key?.toLowerCase();
if (!key) return;
if (pressedKeysRef.current.has(key)) return;
pressedKeysRef.current.add(key);
if (bindingHas(keymap.driveMacro, key)) {
event.preventDefault();
setMode('drive');
runMacro('drive-sequence');
} else if (bindingHas(keymap.dockMacro, key)) {
event.preventDefault();
setMode('dock');
runMacro('seek-dock');
} else if (bindingHas(keymap.cameraUp, key)) {
event.preventDefault();
nudgeServo(servoStep);
} else if (bindingHas(keymap.cameraDown, key)) {
event.preventDefault();
nudgeServo(-servoStep);
}
updateFromKeys();
}
function handleKeyUp(event) {
const key = event.key?.toLowerCase();
if (!key || !pressedKeysRef.current.has(key)) return;
pressedKeysRef.current.delete(key);
updateFromKeys();
}
function handleBlur() {
resetAll();
}
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
}, [keymap, nudgeServo, resetAll, runMacro, servoStep, setMode, updateFromKeys]);
useEffect(() => {
resetAll();
}, [state.roverId, resetAll]);
return null;
}
+36
View File
@@ -0,0 +1,36 @@
import { CONTROL_SETTINGS_COOKIE, CONTROL_SETTINGS_MAX_AGE } from './constants.js';
function parseCookieValue(raw) {
try {
const decoded = decodeURIComponent(raw ?? '');
return JSON.parse(decoded);
} catch (error) {
console.warn('Failed to parse control settings cookie', error); // eslint-disable-line no-console
return null;
}
}
export function loadControlSettings() {
if (typeof document === 'undefined') return null;
const cookiePrefix = `${CONTROL_SETTINGS_COOKIE}=`;
const entry = document.cookie
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(cookiePrefix));
if (!entry) return null;
const raw = entry.substring(cookiePrefix.length);
return parseCookieValue(raw);
}
export function saveControlSettings(settings) {
if (typeof document === 'undefined') return false;
try {
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
const cookie = `${CONTROL_SETTINGS_COOKIE}=${serialized}; path=/; max-age=${CONTROL_SETTINGS_MAX_AGE}; samesite=strict`;
document.cookie = cookie;
return true;
} catch (error) {
console.warn('Failed to write control settings cookie', error); // eslint-disable-line no-console
return false;
}
}
-340
View File
@@ -1,340 +0,0 @@
/* global Buffer */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
const OI_COMMANDS = {
start: [128],
safe: [131],
full: [132],
passive: [128],
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));
}
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 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;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
target.isContentEditable ||
(tag === 'SELECT' && ['w', 'a', 's', 'd', 'Shift'].includes(event.key))
);
}
function bytesToBase64(bytes) {
const binary = String.fromCharCode(...bytes);
if (typeof btoa === 'function') return btoa(binary);
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64');
}
throw new Error('No base64 encoder available');
}
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(() => ({ left: 0, right: 0 }));
const [servoAngle, setServoAngle] = useState(null);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
},
[socket, roverId],
);
const currentRosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(session?.roster)) return null;
return session.roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, session?.roster]);
const servoConfig = useMemo(() => {
if (!currentRosterEntry?.cameraServo || !currentRosterEntry.cameraServo.enabled) {
return null;
}
return currentRosterEntry.cameraServo;
}, [currentRosterEntry]);
const enableSensorStream = useCallback(() => {
if (!roverId) return;
emitCommand({
type: 'sensorStream',
data: { sensorStream: { enable: true } },
});
}, [emitCommand, 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 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);
sendMotorPwm({ main: 0, side: 0, vacuum: 0 });
}, [roverId, sendMotorPwm]);
const sendOiCommand = useCallback(
(key) => {
if (!roverId) return;
const bytes = OI_COMMANDS[key];
if (!bytes) return;
emitCommand({
type: 'raw',
data: { raw: bytesToBase64(bytes) },
});
enableSensorStream();
},
[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],
);
const sendServoCommand = useCallback(
(payload) => {
if (!roverId || !servoConfig) return;
emitCommand({
type: 'servo',
data: { servo: payload },
});
},
[emitCommand, roverId, servoConfig],
);
const applyServoAngle = useCallback(
(angle) => {
if (!servoConfig) return;
const min = typeof servoConfig.minAngle === 'number' ? servoConfig.minAngle : -45;
const max = typeof servoConfig.maxAngle === 'number' ? servoConfig.maxAngle : 45;
const clamped = clamp(angle, min, max);
setServoAngle(clamped);
sendServoCommand({ angle: clamped });
},
[sendServoCommand, servoConfig],
);
const nudgeServo = useCallback(
(delta) => {
if (!servoConfig) return;
const min = typeof servoConfig.minAngle === 'number' ? servoConfig.minAngle : -45;
const max = typeof servoConfig.maxAngle === 'number' ? servoConfig.maxAngle : 45;
const midpoint = (min + max) / 2;
const step =
typeof delta === 'number' && !Number.isNaN(delta) && delta !== 0
? delta
: servoConfig.nudgeDegrees || 1;
const baseline =
typeof servoAngle === 'number'
? servoAngle
: servoConfig.homeAngle ?? midpoint;
applyServoAngle(baseline + step);
},
[applyServoAngle, servoAngle, servoConfig],
);
useEffect(() => {
if (!servoConfig) {
setServoAngle(null);
if (roverId) {
// eslint-disable-next-line no-console
console.debug('Camera servo unavailable for rover', roverId, {
rosterEntry: currentRosterEntry,
});
}
return;
}
const min = typeof servoConfig.minAngle === 'number' ? servoConfig.minAngle : -45;
const max = typeof servoConfig.maxAngle === 'number' ? servoConfig.maxAngle : 45;
const initial =
typeof servoConfig.homeAngle === 'number' ? servoConfig.homeAngle : (min + max) / 2;
setServoAngle(clamp(initial, min, max));
}, [currentRosterEntry, roverId, servoConfig]);
useEffect(() => {
if (!roverId) {
keysRef.current.clear();
lastSpeedsRef.current = { left: 0, right: 0 };
setCurrentSpeeds(lastSpeedsRef.current);
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);
enableSensorStream();
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [roverId, sendDriveUpdate, enableSensorStream]);
return {
roverId,
speeds: currentSpeeds,
stopMotors,
sendOiCommand,
runStartDockFull,
seekDock,
setAuxMotors,
driveWithVector,
servo: {
enabled: Boolean(roverId && servoConfig),
config: servoConfig,
angle:
typeof servoAngle === 'number'
? servoAngle
: servoConfig?.homeAngle ?? servoConfig?.minAngle ?? 0,
setAngle: applyServoAngle,
nudge: nudgeServo,
goHome: () => {
if (!servoConfig) return;
applyServoAngle(servoConfig.homeAngle ?? 0);
},
},
};
}