mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
444 lines
14 KiB
React
444 lines
14 KiB
React
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;
|
|
}
|