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