mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-19 02:50:46 -04:00
slopfixing / issue 002
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
// Control Context Provider
|
||||
// Purpose: Exposes control-system state/actions to control-capable components. Scope: Owns reducer wiring, pipeline integration, and top-level provider hooks.
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { controlReducer, initialControlState } from './controlReducer.js';
|
||||
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
||||
import { useCommandPipeline } from './commandPipeline.js';
|
||||
@@ -27,6 +38,35 @@ import {
|
||||
|
||||
const ControlSystemContext = createContext(null);
|
||||
|
||||
function normalizeSelector(selector) {
|
||||
return typeof selector === 'function' ? selector : (snapshot) => snapshot;
|
||||
}
|
||||
|
||||
const CONTROL_ACTION_NAMES = [
|
||||
'setMode',
|
||||
'setDriveVector',
|
||||
'setAuxMotors',
|
||||
'setServoAngle',
|
||||
'nudgeServo',
|
||||
'goServoHome',
|
||||
'runMacro',
|
||||
'stopAllMotion',
|
||||
'sendOiCommand',
|
||||
'setSensorStream',
|
||||
'setNightVision',
|
||||
'toggleNightVision',
|
||||
'updateKeyBinding',
|
||||
'resetKeyBindings',
|
||||
'registerInputState',
|
||||
'setManualDockAssistActive',
|
||||
'toggleManualDockAssist',
|
||||
'setSongNote',
|
||||
'sendSong',
|
||||
'startHorn',
|
||||
'stopHorn',
|
||||
'setMicPttActive',
|
||||
];
|
||||
|
||||
function cloneKeymap(map) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(map || {}).map(([key, values]) => [key, Array.isArray(values) ? [...values] : []]),
|
||||
@@ -59,6 +99,53 @@ function removeDriveSequenceBackoff(steps = []) {
|
||||
|
||||
export function ControlSystemProvider({ children }) {
|
||||
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
||||
const snapshotRef = useRef(null);
|
||||
const subscribersRef = useRef(new Set());
|
||||
const actionImplementationsRef = useRef({});
|
||||
const [stableActions] = useState(() => {
|
||||
/*
|
||||
Public action functions intentionally keep stable identities. Each wrapper
|
||||
reads the current implementation at call time, so consumers can depend on
|
||||
actions without re-rendering just because the real implementation now
|
||||
closes over a new state slice, pipeline object, or settings value.
|
||||
*/
|
||||
return Object.fromEntries(
|
||||
CONTROL_ACTION_NAMES.map((name) => [
|
||||
name,
|
||||
(...args) => actionImplementationsRef.current[name]?.(...args),
|
||||
]),
|
||||
);
|
||||
}, []);
|
||||
const getControlSnapshot = useCallback(() => snapshotRef.current, []);
|
||||
const subscribeControlSnapshot = useCallback((selector, listener, equalityFn = Object.is) => {
|
||||
const normalizedSelector = normalizeSelector(selector);
|
||||
const currentSnapshot = snapshotRef.current;
|
||||
const subscriber = {
|
||||
selector: normalizedSelector,
|
||||
equalityFn,
|
||||
listener,
|
||||
current: normalizedSelector(currentSnapshot),
|
||||
};
|
||||
subscribersRef.current.add(subscriber);
|
||||
return () => {
|
||||
subscribersRef.current.delete(subscriber);
|
||||
};
|
||||
}, []);
|
||||
const notifyControlSubscribers = useCallback((nextSnapshot) => {
|
||||
/*
|
||||
The provider context value below is stable, so React will not fan out an
|
||||
update to every consumer automatically. This loop is the replacement: it
|
||||
asks each subscribed component whether the exact value it selected changed
|
||||
and only wakes that component when its selected value is different.
|
||||
*/
|
||||
subscribersRef.current.forEach((subscriber) => {
|
||||
const nextSelected = subscriber.selector(nextSnapshot);
|
||||
if (!subscriber.equalityFn(subscriber.current, nextSelected)) {
|
||||
subscriber.current = nextSelected;
|
||||
subscriber.listener(nextSelected);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const prevModeRef = useRef(null);
|
||||
const pendingLightsRef = useRef(false);
|
||||
const servoAngleRef = useRef(initialControlState.camera.angle);
|
||||
@@ -166,7 +253,7 @@ export function ControlSystemProvider({ children }) {
|
||||
if (pipeline.roverId) {
|
||||
pipeline.enableSensorStream();
|
||||
}
|
||||
}, [pipeline.roverId, pipeline.enableSensorStream]);
|
||||
}, [pipeline]);
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode) => {
|
||||
@@ -430,6 +517,8 @@ export function ControlSystemProvider({ children }) {
|
||||
return { waveform, freqs: normalized };
|
||||
}, [hornSettings]);
|
||||
|
||||
const stopHornRef = useRef(null);
|
||||
|
||||
const startHorn = useCallback(() => {
|
||||
if (!pipeline.horn) return;
|
||||
if (state.horn?.overheated) return false;
|
||||
@@ -445,7 +534,7 @@ export function ControlSystemProvider({ children }) {
|
||||
hornAutoStopRef.current = null;
|
||||
}
|
||||
hornAutoStopRef.current = setTimeout(() => {
|
||||
stopHorn();
|
||||
stopHornRef.current?.();
|
||||
}, HORN_MAX_MS);
|
||||
recordControlIntent();
|
||||
return true;
|
||||
@@ -474,15 +563,13 @@ export function ControlSystemProvider({ children }) {
|
||||
}
|
||||
dispatch({ type: 'control/set-horn-heat', payload: { heat: nextHeat, overheated: nextOverheated } });
|
||||
}
|
||||
}, [dispatch, pipeline, state.horn?.heat, state.horn?.overheated, HORN_HEAT_UP_PER_SEC]);
|
||||
}, [dispatch, pipeline, state.horn?.heat, state.horn?.overheated]);
|
||||
|
||||
const hornStateRef = useRef({
|
||||
active: Boolean(state.horn?.active),
|
||||
heat: state.horn?.heat ?? 0,
|
||||
overheated: Boolean(state.horn?.overheated),
|
||||
});
|
||||
const stopHornRef = useRef(stopHorn);
|
||||
|
||||
useEffect(() => {
|
||||
hornStateRef.current = {
|
||||
active: Boolean(state.horn?.active),
|
||||
@@ -525,7 +612,7 @@ export function ControlSystemProvider({ children }) {
|
||||
}
|
||||
}, tickMs);
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch, hornNeedsTick, HORN_HEAT_COOL_PER_SEC, HORN_HEAT_RESUME_THRESHOLD, HORN_HEAT_UP_PER_SEC]);
|
||||
}, [dispatch, hornNeedsTick]);
|
||||
|
||||
const setManualDockAssistActive = useCallback(
|
||||
(active) => {
|
||||
@@ -561,41 +648,32 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
const actionImplementations = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
dispatch,
|
||||
pipeline,
|
||||
overcurrentLimiter,
|
||||
actions: {
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
goServoHome,
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
sendOiCommand,
|
||||
setSensorStream,
|
||||
setNightVision,
|
||||
toggleNightVision,
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setManualDockAssistActive,
|
||||
toggleManualDockAssist,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
},
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
goServoHome,
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
sendOiCommand,
|
||||
setSensorStream,
|
||||
setNightVision,
|
||||
toggleNightVision,
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setManualDockAssistActive,
|
||||
toggleManualDockAssist,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
pipeline,
|
||||
overcurrentLimiter,
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
@@ -621,13 +699,106 @@ export function ControlSystemProvider({ children }) {
|
||||
],
|
||||
);
|
||||
|
||||
return <ControlSystemContext.Provider value={contextValue}>{children}</ControlSystemContext.Provider>;
|
||||
const snapshot = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
dispatch,
|
||||
pipeline,
|
||||
overcurrentLimiter,
|
||||
actions: stableActions,
|
||||
}),
|
||||
[state, pipeline, overcurrentLimiter, stableActions],
|
||||
);
|
||||
|
||||
if (snapshotRef.current == null) {
|
||||
/*
|
||||
The first render has to make a snapshot available synchronously because
|
||||
descendants can call selector hooks during that same render pass. Later
|
||||
renders publish updates from the layout effect below, after React has
|
||||
committed the provider's new state.
|
||||
*/
|
||||
snapshotRef.current = snapshot;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
actionImplementationsRef.current = actionImplementations;
|
||||
}, [actionImplementations]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (snapshotRef.current === snapshot) return;
|
||||
snapshotRef.current = snapshot;
|
||||
notifyControlSubscribers(snapshot);
|
||||
}, [notifyControlSubscribers, snapshot]);
|
||||
|
||||
const store = useMemo(
|
||||
() => ({
|
||||
getSnapshot: getControlSnapshot,
|
||||
subscribe: subscribeControlSnapshot,
|
||||
actions: stableActions,
|
||||
}),
|
||||
[getControlSnapshot, stableActions, subscribeControlSnapshot],
|
||||
);
|
||||
|
||||
return <ControlSystemContext.Provider value={store}>{children}</ControlSystemContext.Provider>;
|
||||
}
|
||||
|
||||
export function useControlSelector(selector, equalityFn = Object.is) {
|
||||
const store = useContext(ControlSystemContext);
|
||||
if (!store) {
|
||||
throw new Error('useControlSelector must be used within ControlSystemProvider');
|
||||
}
|
||||
const selectorRef = useRef(selector);
|
||||
const equalityRef = useRef(equalityFn);
|
||||
|
||||
const [selected, setSelected] = useState(() => selector(store.getSnapshot()));
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
Selector functions are often declared inline at the call site. Refreshing
|
||||
these refs from an effect keeps the subscription callback pointed at the
|
||||
newest selector/equality pair without making render mutate refs.
|
||||
*/
|
||||
selectorRef.current = selector;
|
||||
equalityRef.current = equalityFn;
|
||||
}, [equalityFn, selector]);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
Recheck once after subscribing because the provider may have published a
|
||||
newer snapshot between this component's render and its effect. The equality
|
||||
guard keeps that synchronization from causing a redundant render.
|
||||
*/
|
||||
setSelected((prev) => {
|
||||
const next = selectorRef.current(store.getSnapshot());
|
||||
return equalityRef.current(prev, next) ? prev : next;
|
||||
});
|
||||
|
||||
return store.subscribe(
|
||||
(snapshot) => selectorRef.current(snapshot),
|
||||
(nextSelected) => {
|
||||
setSelected((prev) => (equalityRef.current(prev, nextSelected) ? prev : nextSelected));
|
||||
},
|
||||
(a, b) => equalityRef.current(a, b),
|
||||
);
|
||||
}, [store]);
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function useControlActions() {
|
||||
const store = useContext(ControlSystemContext);
|
||||
if (!store) {
|
||||
throw new Error('useControlActions must be used within ControlSystemProvider');
|
||||
}
|
||||
return store.actions;
|
||||
}
|
||||
|
||||
export function useControlSystem() {
|
||||
const context = useContext(ControlSystemContext);
|
||||
if (!context) {
|
||||
throw new Error('useControlSystem must be used within ControlSystemProvider');
|
||||
}
|
||||
return context;
|
||||
/*
|
||||
This compatibility hook intentionally selects the whole snapshot, so it has
|
||||
the same broad update behavior as the old context API. New and migrated
|
||||
consumers should prefer useControlSelector/useControlActions so they only
|
||||
re-render for data they actually read.
|
||||
*/
|
||||
return useControlSelector((snapshot) => snapshot);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Control Module Exports
|
||||
// Purpose: Re-exports control context/providers and input managers from one entrypoint. Scope: Keeps control imports stable and concise for app/module consumers.
|
||||
export { ControlSystemProvider, useControlSystem } from './ControlContext.jsx';
|
||||
export {
|
||||
ControlSystemProvider,
|
||||
useControlActions,
|
||||
useControlSelector,
|
||||
useControlSystem,
|
||||
} from './ControlContext.jsx';
|
||||
export { default as KeyboardInputManager } from './inputs/KeyboardInputManager.jsx';
|
||||
export { default as GamepadInputManager } from './inputs/GamepadInputManager.jsx';
|
||||
export { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Gamepad Input Manager
|
||||
// Purpose: Converts polled gamepad state into normalized control actions/commands. Scope: Integrates bindings, deadzone math, and dispatch callbacks for driving.
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useControlSystem } from '../ControlContext.jsx';
|
||||
import { useControlActions, useControlSelector } from '../ControlContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||
import {
|
||||
@@ -48,17 +48,16 @@ function pickActivePad(pads, activeSignature) {
|
||||
|
||||
export default function GamepadInputManager() {
|
||||
const {
|
||||
state,
|
||||
actions: {
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
runMacro,
|
||||
toggleNightVision,
|
||||
registerInputState,
|
||||
},
|
||||
} = useControlSystem();
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
runMacro,
|
||||
toggleNightVision,
|
||||
registerInputState,
|
||||
} = useControlActions();
|
||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||
'gamepad',
|
||||
@@ -162,8 +161,8 @@ export default function GamepadInputManager() {
|
||||
// with the newest settings and control actions without resubscribing to the hub.
|
||||
latestRef.current = {
|
||||
activeSignature,
|
||||
cameraAngle: state.camera?.angle,
|
||||
cameraConfig: state.camera?.config,
|
||||
cameraAngle,
|
||||
cameraConfig,
|
||||
dockAssist,
|
||||
gamepadSettings,
|
||||
registerInputState,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Keyboard Input Manager
|
||||
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useControlSystem } from '../ControlContext.jsx';
|
||||
import { useControlActions, useControlSelector } from '../ControlContext.jsx';
|
||||
import { useChat } from '../../context/ChatContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
||||
@@ -83,30 +83,32 @@ function shouldIgnoreEvent(event) {
|
||||
|
||||
export default function KeyboardInputManager() {
|
||||
const {
|
||||
state,
|
||||
actions: {
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
nudgeServo,
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
toggleNightVision,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
} = useControlSystem();
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
nudgeServo,
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
toggleNightVision,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
} = useControlActions();
|
||||
const rawKeymap = useControlSelector((control) => control.state.keymap);
|
||||
const cameraNudgeDegrees = useControlSelector((control) => control.state.camera?.config?.nudgeDegrees);
|
||||
const songNote = useControlSelector((control) => control.state.song?.note);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { homeAssistantSetState } = useSessionActions();
|
||||
const { focusChat, isChatFocused } = useChat();
|
||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||
const { save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
|
||||
const keymap = useMemo(() => normalizeKeymapEntries(state.keymap), [state.keymap]);
|
||||
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);
|
||||
const actionTokens = useMemo(() => {
|
||||
const tokens = new Set();
|
||||
Object.values(keymap).forEach((bindingSet) => {
|
||||
@@ -132,8 +134,8 @@ export default function KeyboardInputManager() {
|
||||
return mapTiltSpeedToInterval(tiltSpeed);
|
||||
}, [inputSettings?.keyboard]);
|
||||
const servoStep = useMemo(
|
||||
() => Math.abs(state.camera?.config?.nudgeDegrees || 1),
|
||||
[state.camera?.config?.nudgeDegrees],
|
||||
() => Math.abs(cameraNudgeDegrees || 1),
|
||||
[cameraNudgeDegrees],
|
||||
);
|
||||
|
||||
const activeTokensRef = useRef(new Set());
|
||||
@@ -352,7 +354,7 @@ export default function KeyboardInputManager() {
|
||||
setMicPttActive,
|
||||
setMode,
|
||||
setSongNote,
|
||||
songNote: state.song?.note,
|
||||
songNote,
|
||||
startHorn,
|
||||
stopAllMotion,
|
||||
stopHorn,
|
||||
@@ -455,11 +457,11 @@ export default function KeyboardInputManager() {
|
||||
|
||||
useEffect(() => {
|
||||
latestResetAllRef.current();
|
||||
}, [state.roverId]);
|
||||
}, [roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
hornActiveRef.current = Boolean(state.horn?.active);
|
||||
}, [state.horn?.active]);
|
||||
hornActiveRef.current = hornActive;
|
||||
}, [hornActive]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
||||
Reference in New Issue
Block a user