Files
MultiRoombaRover/webui/src/controls/ControlContext.jsx
T
2026-09-08 23:11:03 -04:00

916 lines
31 KiB
React

// Control Context Provider
// Purpose: Exposes control-system state/actions to control-capable components. Scope: Owns reducer wiring, pipeline integration, and top-level provider hooks.
/* 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';
import {
DEFAULT_KEYMAP,
DEFAULT_MACROS,
HORN_HEAT_COOL_PER_SEC,
HORN_HEAT_RESUME_THRESHOLD,
HORN_HEAT_UP_PER_SEC,
HORN_MAX_FREQUENCY,
HORN_MAX_MS,
MANUAL_DOCK_ASSIST_MAX_SPEED,
SONG_DEFAULT_NOTE,
} from './constants.js';
import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
import { usePtzControlAdapter } from './ptzControlAdapter.js';
import { trackAnalyticsEvent } from '../analytics/index.js';
const ControlSystemContext = createContext(null);
function normalizeSelector(selector) {
return typeof selector === 'function' ? selector : (snapshot) => snapshot;
}
const CONTROL_ACTION_NAMES = [
'setMode',
'setDriveVector',
'setAuxMotors',
'setServoAngle',
'nudgeServo',
'setCameraAxisIntent',
'goServoHome',
'setCameraPrecisionMode',
'runMacro',
'stopAllMotion',
'sendOiCommand',
'setSensorStream',
'setHeadlight',
'toggleHeadlight',
'setLaser',
'toggleLaser',
'updateKeyBinding',
'resetKeyBindings',
'registerInputState',
'setManualDockAssistActive',
'toggleManualDockAssist',
'setSongNote',
'sendSong',
'startHorn',
'stopHorn',
'setPeripheralControl',
'setMicPttActive',
];
function cloneKeymap(map) {
return Object.fromEntries(
Object.entries(map || {}).map(([key, values]) => [key, Array.isArray(values) ? [...values] : []]),
);
}
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);
}
function removeDriveSequenceBackoff(steps = []) {
if (!Array.isArray(steps) || steps.length < 3) return steps;
const last3 = steps.slice(-3);
const [backoffStep, pauseStep, stopStep] = last3;
const isBackoffStep =
backoffStep?.type === 'drive' &&
Number(backoffStep?.speeds?.left) === -300 &&
Number(backoffStep?.speeds?.right) === -300;
const isPauseStep = pauseStep?.type === 'pause' && Number(pauseStep?.duration) === 600;
const isStopStep =
stopStep?.type === 'drive' &&
Number(stopStep?.speeds?.left) === 0 &&
Number(stopStep?.speeds?.right) === 0;
if (!isBackoffStep || !isPauseStep || !isStopStep) return steps;
return steps.slice(0, -3);
}
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);
const hornAutoStopRef = useRef(null);
const hornStartAtRef = useRef(0);
const hornHeatTickRef = useRef(0);
const {
value: controlSettings,
save: saveControlSettings,
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
const { value: pageSettings } = useSettingsNamespace('page', { driveMacroBackoffEnabled: true });
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
const driveMacroBackoffEnabled =
typeof pageSettings?.driveMacroBackoffEnabled === 'boolean'
? pageSettings.driveMacroBackoffEnabled
: true;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
const roomLightsLocked = useSessionSelector((state) =>
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
);
const roomLightsLockedOn = useSessionSelector((state) =>
Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn),
);
const { homeAssistantSetState } = useSessionActions();
const overcurrentLimiter = useOvercurrentLimiter(roverId);
/*
Motor commands now remain raw until they reach the server-owned protection
service. Applying another transform here would make non-admin commands pass
through two independent limiters and would let browser lifecycle determine
whether protection exists at all.
*/
const pipeline = useCommandPipeline();
const ptzControls = usePtzControlAdapter();
const turnOnAllLights = useCallback(() => {
/*
Automatic drive-mode lighting is convenience behavior for the open room.
A room-light lock is an explicit policy decision, including locked-off,
so this helper must not issue any Home Assistant commands while that
policy is active. Admins can still use the dedicated room controls when
they need to override individual lamps.
*/
if (roomLightsLocked) {
pendingLightsRef.current = false;
return;
}
const entities = homeAssistantEntities || [];
const targets = entities.filter(
(ent) =>
(ent.type === 'light' || ent.type === 'switch') &&
ent.available !== false &&
ent.state !== 'on',
);
if (targets.length === 0) {
pendingLightsRef.current = false;
return;
}
pendingLightsRef.current = true;
targets.forEach((ent) => {
homeAssistantSetState(ent.id, 'on').catch(() => {});
});
// Give the loop a chance; clear pending after issuing commands.
pendingLightsRef.current = false;
}, [homeAssistantEntities, homeAssistantSetState, roomLightsLocked]);
useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
}, [pipeline.roverId]);
useEffect(() => {
const prevMode = prevModeRef.current;
prevModeRef.current = state.mode;
if (prevMode !== 'drive' && state.mode === 'drive' && homeAssistantEntities?.length) {
turnOnAllLights();
} else if (prevMode !== 'drive' && state.mode === 'drive') {
pendingLightsRef.current = true;
}
}, [state.mode, homeAssistantEntities, turnOnAllLights]);
useEffect(() => {
if (pendingLightsRef.current && homeAssistantEntities?.length) {
turnOnAllLights();
}
}, [homeAssistantEntities, turnOnAllLights]);
useEffect(() => {
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
dispatch({ type: 'control/set-keymap', payload: mergedKeymap });
if (controlSettings?.macros) {
dispatch({ type: 'control/set-macros', payload: controlSettings.macros });
}
}, [controlSettings?.keymap, controlSettings?.macros]);
useEffect(() => {
const config = pipeline.servoConfig;
if (!config) {
dispatch({ type: 'control/set-camera-config', payload: { config: null } });
servoAngleRef.current = 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;
const preserved =
typeof servoAngleRef.current === 'number' ? clamp(servoAngleRef.current, min, max) : null;
const angle = preserved != null ? preserved : clamp(base, min, max);
dispatch({
type: 'control/set-camera-config',
payload: { config, angle },
});
servoAngleRef.current = angle;
}, [pipeline.servoConfig]);
useEffect(() => {
servoAngleRef.current =
typeof state.camera.angle === 'number' ? state.camera.angle : servoAngleRef.current;
}, [state.camera.angle]);
useEffect(() => {
if (pipeline.roverId) {
pipeline.enableSensorStream();
}
}, [pipeline.roverId, pipeline.enableSensorStream]);
const setMode = useCallback(
(mode) => {
dispatch({ type: 'control/set-mode', payload: mode });
},
[],
);
const recordControlIntent = useCallback(() => {
dispatch({ type: 'control/record-intent' });
}, []);
const driveEngagementRoverRef = useRef(null);
useEffect(() => {
/* A new assignment gets one meaningful movement conversion event. */
driveEngagementRoverRef.current = null;
}, [pipeline.roverId]);
const setDriveVector = useCallback(
(vector, meta = {}) => {
const speedOptions = { ...(meta.speedOptions || {}) };
if (state.manualDockAssist?.active) {
const capped = Math.max(1, Math.min(MANUAL_DOCK_ASSIST_MAX_SPEED, 500));
speedOptions.maxSpeed = Math.min(
typeof speedOptions.maxSpeed === 'number' ? speedOptions.maxSpeed : capped,
capped,
);
speedOptions.baseSpeed = Math.min(
typeof speedOptions.baseSpeed === 'number' ? speedOptions.baseSpeed : capped,
capped,
);
speedOptions.boostSpeed = Math.min(
typeof speedOptions.boostSpeed === 'number' ? speedOptions.boostSpeed : capped,
capped,
);
}
const computed = computeDifferentialSpeeds(vector, speedOptions);
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
driveEngagementRoverRef.current = pipeline.roverId;
trackAnalyticsEvent('drive_engaged', {
roverId: pipeline.roverId,
source: meta.source || 'unknown',
});
}
dispatch({
type: 'control/update-drive',
payload: { ...computed, source: meta.source ?? null },
});
recordControlIntent();
if (ptzControls.applyDriveVector(vector, meta)) return;
pipeline.sendDriveDirect(computed.speeds);
},
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
);
const setAuxMotors = useCallback(
(values = {}) => {
const payload = pipeline.sendAuxMotors(values) ?? values;
dispatch({
type: 'control/set-aux-motors',
payload,
});
recordControlIntent();
},
[pipeline, recordControlIntent],
);
const updateKeyBinding = useCallback(
(bindingId, keyValue) => {
if (!bindingId) return false;
const canonical = canonicalizeKeyInput(keyValue);
if (!canonical) return false;
const next = cloneKeymap(state.keymap);
next[bindingId] = [canonical];
dispatch({ type: 'control/set-keymap', payload: next });
saveControlSettings((current) => ({
...(current ?? {}),
keymap: next,
}));
return true;
},
[state.keymap, saveControlSettings],
);
const resetKeyBindings = useCallback(() => {
const defaults = cloneKeymap(DEFAULT_KEYMAP);
dispatch({ type: 'control/set-keymap', payload: defaults });
saveControlSettings((current) => ({
...(current ?? {}),
keymap: defaults,
}));
}, [saveControlSettings]);
const setServoAngle = useCallback(
(value, options = {}) => {
/*
Absolute servo positions belong only to rover hardware. PTZ zoom now
enters through setCameraAxisIntent as a signed held velocity, so this
function must not infer zoom direction by comparing unrelated absolute
angle values from gamepad/manual-dock callers.
*/
if (!pipeline.servoConfig) return;
const force = Boolean(options?.force);
if (state.manualDockAssist?.active && !force) return;
const clamped = clampServoAngle(pipeline.servoConfig, value);
dispatch({ type: 'control/set-camera-angle', payload: clamped });
pipeline.sendServoAngle(clamped);
servoAngleRef.current = clamped;
recordControlIntent();
},
[pipeline, recordControlIntent, state.manualDockAssist?.active],
);
const nudgeServo = useCallback(
(delta = 0) => {
if (ptzControls.isActive) {
/*
A PTZ camera has no absolute browser-side servo angle. Treat a nudge
as held zoom direction and, importantly, preserve zero as an explicit
release. The previous fallback converted nudgeServo(0) into a positive
default step, so releasing the mobile zoom button could zoom in again.
*/
ptzControls.setZoomIntent(delta);
recordControlIntent();
return;
}
const config = pipeline.servoConfig;
if (!config) return;
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
const baseline =
typeof servoAngleRef.current === 'number'
? servoAngleRef.current
: typeof config?.homeAngle === 'number'
? config.homeAngle
: 0;
setServoAngle(baseline + step);
},
[pipeline.servoConfig, ptzControls, recordControlIntent, setServoAngle],
);
const setCameraAxisIntent = useCallback(
(direction = 0) => {
/*
Keyboard, touch, and gamepad all need an explicit way to say that a
camera axis returned to neutral. Rover servos remain position/nudge
based, so returning false tells those callers to continue through their
existing rover implementation without introducing PTZ rules there.
*/
if (!ptzControls.isActive) return false;
ptzControls.setZoomIntent(direction);
/*
Do not dispatch recordControlIntent here. Gamepads publish their neutral
and held axes every animation frame; the PTZ adapter deduplicates state
and owns its 250 ms heartbeat, so a React reducer update per frame would
add churn without representing a new user action.
*/
return true;
},
[ptzControls],
);
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 setCameraPrecisionMode = useCallback((active) => {
/*
This action only changes how the browser chooses servo increments. The Pi
already accepts decimal angle targets, so no server or rover command shape
changes are needed for precision camera mode.
*/
dispatch({ type: 'control/set-camera-precision-mode', payload: Boolean(active) });
}, []);
const runMacro = useCallback(
async (macroId) => {
const macro = state.macros.find((item) => item.id === macroId) || null;
if (!macro) return;
let macroToRun = macro;
if (macroId === 'drive-sequence') {
turnOnAllLights();
if (!homeAssistantEntities?.length) {
pendingLightsRef.current = true;
}
recordControlIntent();
if (!driveMacroBackoffEnabled) {
macroToRun = {
...macro,
steps: removeDriveSequenceBackoff(macro.steps),
};
}
}
await pipeline.runMacroSteps(macroToRun);
},
[
driveMacroBackoffEnabled,
pipeline,
recordControlIntent,
homeAssistantEntities,
state.macros,
turnOnAllLights,
],
);
const stopAllMotion = useCallback(() => {
dispatch({
type: 'control/update-drive',
payload: {
vector: { x: 0, y: 0, boost: false },
speeds: { left: 0, right: 0 },
source: 'system-stop',
},
});
if (ptzControls.isActive) {
ptzControls.stopMotion();
return;
}
pipeline.sendDriveDirect({ left: 0, right: 0 });
pipeline.sendAuxMotors({ main: 0, side: 0, vacuum: 0 });
}, [pipeline, ptzControls]);
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 setHeadlight = useCallback(
(headlightOn) => {
if (ptzControls.setSpotlight(headlightOn)) {
recordControlIntent();
return;
}
if (!pipeline.headlight) return;
// Web controls now speak in logical device state. Any electrical
// inversion needed by the actual GPIO driver is handled by roverd's
// activeLow config, so this command stays readable and direct.
const action = typeof headlightOn === 'boolean' ? (headlightOn ? 'on' : 'off') : 'toggle';
pipeline.sendHeadlight(action);
recordControlIntent();
},
[pipeline, ptzControls, recordControlIntent],
);
const toggleHeadlight = useCallback(() => {
setHeadlight();
}, [setHeadlight]);
const setLaser = useCallback(
(laserOn) => {
if (ptzControls.setIr(laserOn)) {
recordControlIntent();
return;
}
if (!pipeline.laser) return;
if (roomLightsLockedOn && laserOn !== false) return;
// The laser shares the same logical toggle contract as the headlight; it
// is separate only because it has its own GPIO pin, UI control, and keybind.
const action = typeof laserOn === 'boolean' ? (laserOn ? 'on' : 'off') : 'toggle';
pipeline.sendLaser(action);
recordControlIntent();
},
[pipeline, ptzControls, recordControlIntent, roomLightsLockedOn],
);
const toggleLaser = useCallback(() => {
setLaser();
}, [setLaser]);
const setSongNote = useCallback(
(note) => {
const next = typeof note === 'number' ? note : SONG_DEFAULT_NOTE;
dispatch({ type: 'control/set-song-note', payload: next });
return next;
},
[],
);
const sendSong = useCallback(
(notes, options) => {
return pipeline.sendSong(notes, options);
},
[pipeline],
);
const normalizedHornSettings = useMemo(() => {
const base = hornSettings ?? HORN_SETTINGS_DEFAULTS;
const waveform = base.waveform === 'sine' ? 'sine' : 'saw';
const freqs = Array.isArray(base.freqs) ? base.freqs : HORN_SETTINGS_DEFAULTS.freqs;
const normalized = [...freqs, 0, 0, 0, 0]
.slice(0, 4)
.map((value) => {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
return num <= 0 ? 0 : Math.min(HORN_MAX_FREQUENCY, Math.round(num));
});
return { waveform, freqs: normalized };
}, [hornSettings]);
const stopHornRef = useRef(null);
const startHorn = useCallback(() => {
if (!pipeline.horn) return;
if (state.horn?.overheated) return false;
if ((state.horn?.heat ?? 0) >= 1) return false;
if (state.horn?.active) return false;
const now = Date.now();
pipeline.sendHorn({ action: 'start', ...normalizedHornSettings });
dispatch({ type: 'control/set-horn-active', payload: true });
hornStartAtRef.current = now;
hornHeatTickRef.current = now;
if (hornAutoStopRef.current) {
clearTimeout(hornAutoStopRef.current);
hornAutoStopRef.current = null;
}
hornAutoStopRef.current = setTimeout(() => {
stopHornRef.current?.();
}, HORN_MAX_MS);
recordControlIntent();
return true;
}, [dispatch, normalizedHornSettings, pipeline, recordControlIntent, state.horn?.active, state.horn?.heat, state.horn?.overheated]);
const stopHorn = useCallback(() => {
if (!pipeline.horn) return;
pipeline.sendHorn({ action: 'stop' });
dispatch({ type: 'control/set-horn-active', payload: false });
if (hornAutoStopRef.current) {
clearTimeout(hornAutoStopRef.current);
hornAutoStopRef.current = null;
}
const now = Date.now();
const startedAt = hornStartAtRef.current || now;
const holdMs = Math.max(0, now - startedAt);
hornStartAtRef.current = 0;
if (holdMs > 0) {
const currentHeat = state.horn?.heat ?? 0;
const added = (holdMs / 1000) * HORN_HEAT_UP_PER_SEC;
let nextHeat = Math.max(0, Math.min(1, currentHeat + added));
let nextOverheated = state.horn?.overheated ?? false;
if (nextHeat >= 1) {
nextHeat = 1;
nextOverheated = true;
}
dispatch({ type: 'control/set-horn-heat', payload: { heat: nextHeat, overheated: nextOverheated } });
}
}, [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),
});
useEffect(() => {
hornStateRef.current = {
active: Boolean(state.horn?.active),
heat: state.horn?.heat ?? 0,
overheated: Boolean(state.horn?.overheated),
};
}, [state.horn?.active, state.horn?.heat, state.horn?.overheated]);
useEffect(() => {
stopHornRef.current = stopHorn;
}, [stopHorn]);
const hornNeedsTick = Boolean(state.horn?.active) || (state.horn?.heat ?? 0) > 0 || Boolean(state.horn?.overheated);
useEffect(() => {
if (!hornNeedsTick) return undefined;
const tickMs = 100;
const interval = setInterval(() => {
const now = Date.now();
const last = hornHeatTickRef.current || now;
hornHeatTickRef.current = now;
const dt = Math.min(1000, Math.max(0, now - last)) / 1000;
const current = hornStateRef.current;
const currentHeat = current.heat ?? 0;
const active = Boolean(current.active);
const overheated = Boolean(current.overheated);
const delta = dt * (active ? HORN_HEAT_UP_PER_SEC : -HORN_HEAT_COOL_PER_SEC);
let nextHeat = Math.max(0, Math.min(1, currentHeat + delta));
let nextOverheated = overheated;
if (nextHeat >= 1) {
nextHeat = 1;
nextOverheated = true;
if (active) {
stopHornRef.current();
}
} else if (nextOverheated && nextHeat <= HORN_HEAT_RESUME_THRESHOLD) {
nextOverheated = false;
}
if (nextHeat !== currentHeat || nextOverheated !== overheated) {
dispatch({ type: 'control/set-horn-heat', payload: { heat: nextHeat, overheated: nextOverheated } });
}
}, tickMs);
return () => clearInterval(interval);
}, [dispatch, hornNeedsTick]);
const setManualDockAssistActive = useCallback(
(active) => {
const nextActive = Boolean(active);
const prevActive = Boolean(state.manualDockAssist?.active);
if (prevActive === nextActive) return;
dispatch({ type: 'control/set-manual-dock-assist', payload: nextActive });
if (!nextActive) {
pipeline.sendSong([{ note: 83, duration: 10 }, { note: 76, duration: 10 }], { slot: 0 });
setServoAngle(0, { force: true });
return;
}
const minAngle =
typeof pipeline.servoConfig?.minAngle === 'number'
? pipeline.servoConfig.minAngle
: -45;
setServoAngle(minAngle, { force: true });
pipeline.sendSong([{ note: 76, duration: 10 }, { note: 83, duration: 10 }], { slot: 0 });
recordControlIntent();
},
[pipeline, recordControlIntent, setServoAngle, state.manualDockAssist?.active],
);
const toggleManualDockAssist = useCallback(() => {
setManualDockAssistActive(!state.manualDockAssist?.active);
}, [setManualDockAssistActive, state.manualDockAssist?.active]);
const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []);
const setMicPttActive = useCallback((active) => {
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
}, []);
const setPeripheralControl = useCallback(
(peripheralId, controlId, value) => {
const sent = pipeline.sendPeripheralControl(peripheralId, controlId, value);
if (sent) {
// The generic protocol is currently command-only. Recording the value
// here lets every renderer instance share the browser's latest intent
// without pretending that it is device-reported telemetry.
dispatch({
type: 'control/set-peripheral-value',
payload: {
roverId: pipeline.roverId,
peripheralId,
controlId,
value,
},
});
recordControlIntent();
}
return sent;
},
[pipeline, recordControlIntent],
);
const actionImplementations = useMemo(
() => ({
setMode,
setDriveVector,
setAuxMotors,
setServoAngle,
nudgeServo,
setCameraAxisIntent,
goServoHome,
setCameraPrecisionMode,
runMacro,
stopAllMotion,
sendOiCommand,
setSensorStream,
setHeadlight,
toggleHeadlight,
setLaser,
toggleLaser,
updateKeyBinding,
resetKeyBindings,
registerInputState,
setManualDockAssistActive,
toggleManualDockAssist,
setSongNote,
sendSong,
startHorn,
stopHorn,
setPeripheralControl,
setMicPttActive,
}),
[
setMode,
setDriveVector,
setAuxMotors,
setServoAngle,
nudgeServo,
setCameraAxisIntent,
goServoHome,
setCameraPrecisionMode,
runMacro,
stopAllMotion,
sendOiCommand,
setSensorStream,
setHeadlight,
toggleHeadlight,
setLaser,
toggleLaser,
updateKeyBinding,
resetKeyBindings,
registerInputState,
setManualDockAssistActive,
toggleManualDockAssist,
setSongNote,
sendSong,
startHorn,
stopHorn,
setPeripheralControl,
setMicPttActive,
],
);
const snapshot = useMemo(
() => ({
state,
dispatch,
pipeline,
ptzControls,
overcurrentLimiter,
actions: stableActions,
}),
[state, pipeline, ptzControls, 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() {
/*
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);
}