import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useControlSystem } from '../ControlContext.jsx'; import { useChat } from '../../context/ChatContext.jsx'; import { useSession } from '../../context/SessionContext.jsx'; import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js'; import { useSettingsNamespace } from '../../settings/index.js'; import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { SONG_DEFAULT_DURATION, SONG_DEFAULT_NOTE, SONG_NOTE_RANGE, SONG_REPEAT_MS, } from '../constants.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; function clampSpeed(value, fallback) { const num = Number(value); if (!Number.isFinite(num)) return fallback; return Math.max(0, Math.min(500, num)); } 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) { const target = event.target; if (!target) return false; const tag = target.tagName; return ( tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable || tag === 'SELECT' ); } 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 }; } export default function KeyboardInputManager() { const { state, actions: { setMode, setDriveVector, setAuxMotors, nudgeServo, runMacro, stopAllMotion, registerInputState, toggleNightVision, setSongNote, sendSong, }, } = useControlSystem(); const { session, homeAssistantSetState } = useSession(); const { focusChat, blurChat, isChatFocused } = useChat(); const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS); const keymap = useMemo(() => normalizeKeymapEntries(state.keymap), [state.keymap]); 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(() => { const defaults = INPUT_SETTINGS_DEFAULTS.keyboard; const current = inputSettings?.keyboard ?? {}; return { baseSpeed: clampSpeed(current.baseSpeed, defaults.baseSpeed), turboSpeed: clampSpeed(current.turboSpeed, defaults.turboSpeed), precisionSpeed: clampSpeed(current.precisionSpeed, defaults.precisionSpeed), }; }, [inputSettings?.keyboard]); 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(state.camera?.config?.nudgeDegrees || 1), [state.camera?.config?.nudgeDegrees], ); const activeTokensRef = useRef(new Set()); const lastVectorRef = useRef(ZERO_VECTOR); const lastAuxRef = useRef(ZERO_AUX); const servoIntervalRef = useRef(null); const songIntervalRef = useRef(null); const driveFromKeys = useCallback(() => { const tokensSnapshot = new Set(activeTokensRef.current); const boostActive = bindingActive(keymap.boostModifier, tokensSnapshot); const slowActive = bindingActive(keymap.slowModifier, tokensSnapshot); const speedOptions = slowActive ? { baseSpeed: keyboardSpeeds.precisionSpeed, boostSpeed: keyboardSpeeds.precisionSpeed } : { baseSpeed: keyboardSpeeds.baseSpeed, boostSpeed: keyboardSpeeds.turboSpeed }; const vector = computeDriveVector(tokensSnapshot, keymap); const aux = computeAuxMotors(tokensSnapshot, keymap); if ( vector.x !== lastVectorRef.current.x || vector.y !== lastVectorRef.current.y || vector.boost !== lastVectorRef.current.boost ) { lastVectorRef.current = vector; setDriveVector(vector, { source: SOURCE, speedOptions }); } if ( aux.main !== lastAuxRef.current.main || aux.side !== lastAuxRef.current.side || aux.vacuum !== lastAuxRef.current.vacuum ) { lastAuxRef.current = aux; setAuxMotors(aux); } registerInputState(SOURCE, { keys: Array.from(tokensSnapshot), vector, aux, }); }, [keymap, keyboardSpeeds, registerInputState, setAuxMotors, setDriveVector]); const stopServoLoop = useCallback(() => { if (servoIntervalRef.current) { clearTimeout(servoIntervalRef.current); servoIntervalRef.current = null; } }, []); const computeServoDirection = useCallback(() => { const tokensSnapshot = new Set(activeTokensRef.current); const up = bindingActive(keymap.cameraUp, tokensSnapshot); const down = bindingActive(keymap.cameraDown, tokensSnapshot); return (up ? 1 : 0) - (down ? 1 : 0); }, [keymap]); 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; } nudgeServo(nextDirection * servoStep); servoIntervalRef.current = setTimeout(tick, servoRepeatMs); }; servoIntervalRef.current = setTimeout(tick, 0); }, [computeServoDirection, nudgeServo, servoRepeatMs, servoStep, stopServoLoop]); const stopSongLoop = useCallback(() => { if (songIntervalRef.current) { clearTimeout(songIntervalRef.current); songIntervalRef.current = null; } }, []); const computeSongDirection = useCallback(() => { const tokensSnapshot = new Set(activeTokensRef.current); const up = bindingActive(keymap.songNoteUp, tokensSnapshot); const down = bindingActive(keymap.songNoteDown, tokensSnapshot); return (up ? 1 : 0) - (down ? 1 : 0); }, [keymap]); const triggerSongChange = useCallback( (direction) => { if (direction === 0) return; const current = typeof state.song?.note === 'number' ? state.song.note : SONG_DEFAULT_NOTE; let next = current + direction; if (next > NOTE_MAX) { next = NOTE_MIN; } else if (next < NOTE_MIN) { next = NOTE_MAX; } const finalNote = setSongNote(next); sendSong([{ note: finalNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 }); }, [sendSong, setSongNote, state.song?.note], ); 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(() => { activeTokensRef.current.clear(); lastVectorRef.current = ZERO_VECTOR; lastAuxRef.current = ZERO_AUX; stopServoLoop(); stopSongLoop(); stopAllMotion(); registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX }); }, [registerInputState, stopAllMotion, stopServoLoop, stopSongLoop]); const triggerHomeAssistantCycle = useCallback( (targetState) => { const ha = session?.homeAssistant; if (!ha?.enabled || !ha?.connected) 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) { 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') { homeAssistantSetState(ent.id, 'off').catch(() => {}); break; } } } }, [homeAssistantSetState, session?.homeAssistant], ); useEffect(() => { function handleKeyDown(event) { if (shouldIgnoreEvent(event)) return; const tokens = tokensForEvent(event); if (tokens.length === 0) return; const tokenSet = new Set(tokens); if (bindingActive(keymap.chatFocus, tokenSet)) { event.preventDefault(); resetAll(); if (!isChatFocused) { focusChat(); } return; } if (tokens.some((token) => 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) => keymap.driveMacro?.has(token))) { setMode('drive'); runMacro('drive-sequence'); } else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) { setMode('dock'); runMacro('seek-dock'); } else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) { toggleNightVision(); } else if (newlyPressed.some((token) => keymap.homeAssistantOn?.has(token))) { triggerHomeAssistantCycle('on'); } else if (newlyPressed.some((token) => keymap.homeAssistantOff?.has(token))) { triggerHomeAssistantCycle('off'); } } ensureServoLoop(); ensureSongLoop(); driveFromKeys(); } function handleKeyUp(event) { const tokens = tokensForEvent(event); tokens.forEach((token) => activeTokensRef.current.delete(token)); ensureServoLoop(); ensureSongLoop(); driveFromKeys(); } 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); }; }, [ actionTokens, blurChat, driveFromKeys, ensureServoLoop, ensureSongLoop, focusChat, isChatFocused, keymap.chatFocus, keymap.dockMacro, keymap.driveMacro, resetAll, runMacro, setMode, stopAllMotion, stopSongLoop, triggerHomeAssistantCycle, ]); const latestResetAllRef = useRef(resetAll); useEffect(() => { latestResetAllRef.current = resetAll; }, [resetAll]); useEffect(() => { latestResetAllRef.current(); }, [state.roverId]); useEffect( () => () => { stopServoLoop(); stopSongLoop(); }, [stopServoLoop, stopSongLoop], ); return null; }