mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
guh
This commit is contained in:
+11
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user