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