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
+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);
},
},
};
}