Files
MultiRoombaRover/webui/src/controls/inputs/KeyboardInputManager.jsx
T
2026-06-19 01:22:42 -04:00

482 lines
17 KiB
React

// 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 { useControlActions, useControlSelector } from '../ControlContext.jsx';
import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
import { isTextInputElement } from './inputFocusUtils.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import {
SONG_DEFAULT_DURATION,
SONG_DEFAULT_NOTE,
SONG_NOTE_RANGE,
SONG_REPEAT_MS,
} from '../constants.js';
import {
bindingActive,
computeKeyboardAuxMotors,
computeKeyboardDriveVector,
getKeyboardDriveSpeedOptions,
resolveKeyboardSpeeds,
} from './driveIntent.js';
import { trackAnalyticsEvent } from '../../analytics/index.js';
const SOURCE = 'keyboard';
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
const NOTE_MIN = SONG_NOTE_RANGE[0];
const NOTE_MAX = SONG_NOTE_RANGE[1];
const TILT_INTERVAL_MIN = 5;
const TILT_INTERVAL_MAX = 500;
const TILT_SPEED_MIN = 1;
const TILT_SPEED_MAX = 100;
const VIDEO_FILTER_SEQUENCE = ['none', 'grayscale', 'greenscale'];
function normalizeVideoFilter(value) {
// The setting is persisted in a browser cookie and can become stale if filter names change.
// Normalizing before cycling keeps the shortcut deterministic instead of getting stuck on an
// unknown value.
return VIDEO_FILTER_SEQUENCE.includes(value) ? value : VIDEO_SETTINGS_DEFAULTS.colorFilter;
}
function nextVideoFilter(value) {
const current = normalizeVideoFilter(value);
const currentIndex = VIDEO_FILTER_SEQUENCE.indexOf(current);
// The modulo wrap intentionally makes the shortcut a simple single-key cycle:
// Color -> Gray -> Green -> Color. That is faster while driving than needing separate keys.
return VIDEO_FILTER_SEQUENCE[(currentIndex + 1) % VIDEO_FILTER_SEQUENCE.length];
}
function clampTiltInterval(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(TILT_INTERVAL_MIN, Math.min(TILT_INTERVAL_MAX, num));
}
function clampTiltSpeed(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(TILT_SPEED_MIN, Math.min(TILT_SPEED_MAX, num));
}
function mapTiltSpeedToInterval(speed) {
const clampedSpeed = clampTiltSpeed(speed, speed);
const ratio = (clampedSpeed - TILT_SPEED_MIN) / (TILT_SPEED_MAX - TILT_SPEED_MIN);
const interval = TILT_INTERVAL_MAX - ratio * (TILT_INTERVAL_MAX - TILT_INTERVAL_MIN);
return Math.round(interval);
}
function mapTiltIntervalToSpeed(interval) {
const clampedInterval = clampTiltInterval(interval, interval);
const ratio = (TILT_INTERVAL_MAX - clampedInterval) / (TILT_INTERVAL_MAX - TILT_INTERVAL_MIN);
const speed = TILT_SPEED_MIN + ratio * (TILT_SPEED_MAX - TILT_SPEED_MIN);
return Math.round(speed);
}
function shouldIgnoreEvent(event) {
return isTextInputElement(event?.target);
}
export default function KeyboardInputManager() {
const {
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 } = useChatActions();
const { isChatFocused } = useChatFocus();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
const { save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);
const actionTokens = useMemo(() => {
const tokens = new Set();
Object.values(keymap).forEach((bindingSet) => {
if (!bindingSet) return;
bindingSet.forEach((token) => tokens.add(token));
});
return tokens;
}, [keymap]);
const keyboardSpeeds = useMemo(() => {
return resolveKeyboardSpeeds(inputSettings);
}, [inputSettings]);
const servoRepeatMs = useMemo(() => {
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
const current = inputSettings?.keyboard ?? {};
const tiltSpeed = clampTiltSpeed(
typeof current.tiltSpeed === 'number'
? current.tiltSpeed
: typeof current.tiltIntervalMs === 'number'
? mapTiltIntervalToSpeed(current.tiltIntervalMs)
: defaults.tiltSpeed,
defaults.tiltSpeed,
);
return mapTiltSpeedToInterval(tiltSpeed);
}, [inputSettings?.keyboard]);
const servoStep = useMemo(
() => Math.abs(cameraNudgeDegrees || 1),
[cameraNudgeDegrees],
);
const activeTokensRef = useRef(new Set());
const lastVectorRef = useRef(ZERO_VECTOR);
const lastAuxRef = useRef(ZERO_AUX);
const servoIntervalRef = useRef(null);
const songIntervalRef = useRef(null);
const hornActiveRef = useRef(false);
// Global keyboard listeners must stay mounted while React state churns underneath them. This
// ref is rewritten after every commit so the stable handlers can still use fresh settings,
// keymaps, and action callbacks without making listener registration depend on those values.
const latestRef = useRef(null);
const driveFromKeys = useCallback(() => {
const latest = latestRef.current;
if (!latest) return;
const tokensSnapshot = new Set(activeTokensRef.current);
const speedOptions = getKeyboardDriveSpeedOptions(tokensSnapshot, latest.keymap, latest.keyboardSpeeds);
const vector = computeKeyboardDriveVector(tokensSnapshot, latest.keymap);
const aux = computeKeyboardAuxMotors(tokensSnapshot, latest.keymap);
if (
vector.x !== lastVectorRef.current.x ||
vector.y !== lastVectorRef.current.y ||
vector.boost !== lastVectorRef.current.boost
) {
lastVectorRef.current = vector;
latest.setDriveVector(vector, { source: SOURCE, speedOptions });
}
if (
aux.main !== lastAuxRef.current.main ||
aux.side !== lastAuxRef.current.side ||
aux.vacuum !== lastAuxRef.current.vacuum
) {
lastAuxRef.current = aux;
latest.setAuxMotors(aux);
}
latest.registerInputState(SOURCE, {
keys: Array.from(tokensSnapshot),
vector,
aux,
});
}, []);
const stopServoLoop = useCallback(() => {
if (servoIntervalRef.current) {
clearTimeout(servoIntervalRef.current);
servoIntervalRef.current = null;
}
}, []);
const computeServoDirection = useCallback(() => {
const latest = latestRef.current;
if (!latest) return 0;
const tokensSnapshot = new Set(activeTokensRef.current);
const up = bindingActive(latest.keymap.cameraUp, tokensSnapshot);
const down = bindingActive(latest.keymap.cameraDown, tokensSnapshot);
return (up ? 1 : 0) - (down ? 1 : 0);
}, []);
const ensureServoLoop = useCallback(() => {
const direction = computeServoDirection();
if (direction === 0) {
stopServoLoop();
return;
}
if (servoIntervalRef.current) {
return;
}
const tick = () => {
const nextDirection = computeServoDirection();
if (nextDirection === 0) {
stopServoLoop();
return;
}
const latest = latestRef.current;
if (!latest) {
stopServoLoop();
return;
}
latest.nudgeServo(nextDirection * latest.servoStep);
servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs);
};
servoIntervalRef.current = setTimeout(tick, 0);
}, [computeServoDirection, stopServoLoop]);
const stopSongLoop = useCallback(() => {
if (songIntervalRef.current) {
clearTimeout(songIntervalRef.current);
songIntervalRef.current = null;
}
}, []);
const computeSongDirection = useCallback(() => {
const latest = latestRef.current;
if (!latest) return 0;
const tokensSnapshot = new Set(activeTokensRef.current);
const up = bindingActive(latest.keymap.songNoteUp, tokensSnapshot);
const down = bindingActive(latest.keymap.songNoteDown, tokensSnapshot);
return (up ? 1 : 0) - (down ? 1 : 0);
}, []);
const triggerSongChange = useCallback((direction) => {
const latest = latestRef.current;
if (!latest || direction === 0) return;
const current = typeof latest.songNote === 'number' ? latest.songNote : SONG_DEFAULT_NOTE;
let next = current + direction;
if (next > NOTE_MAX) {
next = NOTE_MIN;
} else if (next < NOTE_MIN) {
next = NOTE_MAX;
}
const finalNote = latest.setSongNote(next);
latest.sendSong([{ note: finalNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
}, []);
const ensureSongLoop = useCallback(() => {
const direction = computeSongDirection();
if (direction === 0) {
stopSongLoop();
return;
}
if (songIntervalRef.current) {
return;
}
const tick = () => {
const nextDirection = computeSongDirection();
if (nextDirection === 0) {
stopSongLoop();
return;
}
triggerSongChange(nextDirection);
songIntervalRef.current = setTimeout(tick, SONG_REPEAT_MS);
};
songIntervalRef.current = setTimeout(tick, 0);
}, [computeSongDirection, stopSongLoop, triggerSongChange]);
const resetAll = useCallback(() => {
const latest = latestRef.current;
activeTokensRef.current.clear();
lastVectorRef.current = ZERO_VECTOR;
lastAuxRef.current = ZERO_AUX;
stopServoLoop();
stopSongLoop();
if (hornActiveRef.current) {
hornActiveRef.current = false;
latest?.stopHorn();
}
latest?.setMicPttActive(false);
latest?.stopAllMotion();
latest?.registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
}, [stopServoLoop, stopSongLoop]);
const triggerHomeAssistantCycle = useCallback((targetState) => {
const latest = latestRef.current;
const ha = latest?.homeAssistant;
if (!ha?.enabled || !ha?.connected) return;
if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return;
const entities = ha.entities || [];
const eligible = entities.filter(
(ent) =>
(ent.type === 'light' || ent.type === 'switch') &&
ent.available !== false &&
ent.state !== 'unavailable',
);
if (eligible.length === 0) return;
if (targetState === 'on') {
const next = eligible.find((ent) => ent.state !== 'on');
if (next) {
latest.homeAssistantSetState(next.id, 'on').catch(() => {});
}
return;
}
if (targetState === 'off') {
for (let idx = eligible.length - 1; idx >= 0; idx -= 1) {
const ent = eligible[idx];
if (ent.state === 'on') {
latest.homeAssistantSetState(ent.id, 'off').catch(() => {});
break;
}
}
}
}, []);
const cycleVideoFilter = useCallback(() => {
// Update through the same settings namespace as the Page settings dropdown so the keyboard
// shortcut and menu never maintain separate copies of the selected filter.
const latest = latestRef.current;
latest?.saveVideoSettings((current) => ({
...(current ?? {}),
colorFilter: nextVideoFilter(current?.colorFilter),
}));
}, []);
useLayoutEffect(() => {
// The assignment lives in a layout effect instead of render because the current React lint
// rules reserve refs for effects and event handlers. Layout timing keeps the ref current
// before the browser can deliver the next keyboard event after a committed render.
latestRef.current = {
actionTokens,
dockAssist,
focusChat,
homeAssistant,
homeAssistantSetState,
isChatFocused,
keyboardSpeeds,
keymap,
nudgeServo,
registerInputState,
roverId,
runMacro,
saveVideoSettings,
sendSong,
servoRepeatMs,
servoStep,
setAuxMotors,
setDriveVector,
setMicPttActive,
setMode,
setSongNote,
songNote,
startHorn,
stopAllMotion,
stopHorn,
toggleNightVision,
};
});
useEffect(() => {
function handleKeyDown(event) {
const latest = latestRef.current;
if (!latest) return;
if (isKeyboardCaptureLocked()) return;
if (shouldIgnoreEvent(event)) return;
const tokens = tokensForEvent(event);
if (tokens.length === 0) return;
const tokenSet = new Set(tokens);
if (bindingActive(latest.keymap.chatFocus, tokenSet)) {
event.preventDefault();
resetAll();
if (!latest.isChatFocused) {
latest.focusChat();
}
return;
}
if (tokens.some((token) => latest.actionTokens.has(token))) {
event.preventDefault();
}
const newlyPressed = tokens.filter((token) => !activeTokensRef.current.has(token));
newlyPressed.forEach((token) => activeTokensRef.current.add(token));
if (newlyPressed.length > 0) {
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
trackAnalyticsEvent('drive_start', { roverId: latest.roverId || '', source: 'keyboard' });
latest.dockAssist.exitAssist();
latest.setMode('drive');
latest.runMacro('drive-sequence');
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
latest.dockAssist.toggleAssist();
} else if (newlyPressed.some((token) => latest.keymap.nightVisionToggle?.has(token))) {
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
latest.toggleNightVision();
} else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
cycleVideoFilter();
} else if (newlyPressed.some((token) => latest.keymap.hornHonk?.has(token))) {
if (!hornActiveRef.current) {
const started = latest.startHorn();
hornActiveRef.current = Boolean(started);
}
} else if (newlyPressed.some((token) => latest.keymap.micPtt?.has(token))) {
latest.setMicPttActive(true);
} else if (newlyPressed.some((token) => latest.keymap.homeAssistantOn?.has(token))) {
triggerHomeAssistantCycle('on');
} else if (newlyPressed.some((token) => latest.keymap.homeAssistantOff?.has(token))) {
triggerHomeAssistantCycle('off');
}
}
ensureServoLoop();
ensureSongLoop();
driveFromKeys();
}
function handleKeyUp(event) {
const latest = latestRef.current;
if (!latest) return;
if (isKeyboardCaptureLocked()) return;
const tokens = tokensForEvent(event);
tokens.forEach((token) => activeTokensRef.current.delete(token));
if (hornActiveRef.current && !bindingActive(latest.keymap.hornHonk, activeTokensRef.current)) {
hornActiveRef.current = false;
latest.stopHorn();
}
if (!bindingActive(latest.keymap.micPtt, activeTokensRef.current)) {
latest.setMicPttActive(false);
}
ensureServoLoop();
ensureSongLoop();
driveFromKeys();
}
function handleBlur() {
resetAll();
}
// The global listeners are intentionally registered through stable handlers. High-frequency
// control context updates should refresh latestRef.current, not force the browser to detach
// and reattach window listeners while someone may be driving.
window.addEventListener('keydown', handleKeyDown, { capture: true });
window.addEventListener('keyup', handleKeyUp, { capture: true });
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true });
window.removeEventListener('keyup', handleKeyUp, { capture: true });
window.removeEventListener('blur', handleBlur);
};
}, [cycleVideoFilter, driveFromKeys, ensureServoLoop, ensureSongLoop, resetAll, triggerHomeAssistantCycle]);
const latestResetAllRef = useRef(resetAll);
useEffect(() => {
latestResetAllRef.current = resetAll;
}, [resetAll]);
useEffect(() => {
latestResetAllRef.current();
}, [roverId]);
useEffect(() => {
hornActiveRef.current = hornActive;
}, [hornActive]);
useEffect(
() => () => {
stopServoLoop();
stopSongLoop();
},
[stopServoLoop, stopSongLoop],
);
return null;
}