mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
guh
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user