mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-19 02:50:46 -04:00
glorp!
This commit is contained in:
@@ -3,21 +3,38 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../ControlContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||
import {
|
||||
GAMEPAD_SETTINGS_DEFAULTS,
|
||||
GAMEPAD_PROFILE_DEFAULT,
|
||||
VIDEO_SETTINGS_DEFAULTS,
|
||||
} from '../../settings/namespaces.js';
|
||||
import {
|
||||
advanceCameraAngle,
|
||||
computeGamepadOutputs,
|
||||
createProfileForPad,
|
||||
getPadSignature,
|
||||
resolveGamepadProfile,
|
||||
} from './gamepadBindings.js';
|
||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import {
|
||||
isControllerControlLocked,
|
||||
markControllerDisconnected,
|
||||
markControllerInputActive,
|
||||
} from './controllerRuntime.js';
|
||||
import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { SONG_DEFAULT_DURATION, SONG_DEFAULT_NOTE, SONG_NOTE_RANGE } from '../constants.js';
|
||||
|
||||
const SOURCE = 'gamepad';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||
const DRIVE_RATE_MS = 100;
|
||||
const AUX_RATE_MS = 100;
|
||||
const CONTROLLER_ACTIVITY_AXIS_MIN = 0.24;
|
||||
const CONTROLLER_ACTIVITY_AXIS_DELTA = 0.08;
|
||||
const VIDEO_FILTER_SEQUENCE = ['none', 'grayscale', 'greenscale'];
|
||||
|
||||
function areVectorsEqual(a, b) {
|
||||
return a && b && a.x === b.x && a.y === b.y && a.boost === b.boost;
|
||||
@@ -37,15 +54,63 @@ function isAuxIdle(aux) {
|
||||
return !aux.main && !aux.side && !aux.vacuum;
|
||||
}
|
||||
|
||||
function pickActivePad(pads, activeSignature) {
|
||||
function pickActivePad(pads, activeInstanceKey) {
|
||||
if (!pads || pads.length === 0) return null;
|
||||
if (activeSignature) {
|
||||
const match = pads.find((pad) => pad.signature === activeSignature);
|
||||
if (activeInstanceKey) {
|
||||
const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
|
||||
if (match) return match;
|
||||
}
|
||||
return pads[0];
|
||||
}
|
||||
|
||||
function hasMeaningfulControllerChange(pad, previous) {
|
||||
if (!previous) {
|
||||
return pad.buttons.some((button) => button.pressed) ||
|
||||
pad.axes.some((axis) => Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN);
|
||||
}
|
||||
const buttonPressed = pad.buttons.some(
|
||||
(button, index) => button.pressed && !previous.buttons?.[index]?.pressed,
|
||||
);
|
||||
if (buttonPressed) return true;
|
||||
return pad.axes.some((axis, index) => {
|
||||
const oldAxis = previous.axes?.[index] ?? 0;
|
||||
return Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN &&
|
||||
Math.abs(axis - oldAxis) >= CONTROLLER_ACTIVITY_AXIS_DELTA;
|
||||
});
|
||||
}
|
||||
|
||||
function isControllerNeutral(pad) {
|
||||
return !pad.buttons.some((button) => button.pressed || button.value > 0.1) &&
|
||||
!pad.axes.some((axis) => Math.abs(axis) > 0.2);
|
||||
}
|
||||
|
||||
function nextVideoFilter(value) {
|
||||
const index = VIDEO_FILTER_SEQUENCE.indexOf(value);
|
||||
return VIDEO_FILTER_SEQUENCE[(index < 0 ? 0 : index + 1) % VIDEO_FILTER_SEQUENCE.length];
|
||||
}
|
||||
|
||||
function cycleHomeAssistant(latest, targetState) {
|
||||
const homeAssistant = latest.homeAssistant;
|
||||
if (!homeAssistant?.enabled || !homeAssistant?.connected) return;
|
||||
if (
|
||||
(homeAssistant.lightPolicy?.locked || homeAssistant.lightPolicy?.lockedOn) &&
|
||||
!latest.adminCanControlLockedLights
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const entities = (homeAssistant.entities ?? []).filter(
|
||||
(entity) =>
|
||||
(entity.type === 'light' || entity.type === 'switch') &&
|
||||
entity.available !== false &&
|
||||
entity.state !== 'unavailable',
|
||||
);
|
||||
const ordered = targetState === 'on' ? entities : [...entities].reverse();
|
||||
const next = ordered.find((entity) =>
|
||||
targetState === 'on' ? entity.state !== 'on' : entity.state === 'on',
|
||||
);
|
||||
if (next) latest.homeAssistantSetState(next.id, targetState).catch(() => {});
|
||||
}
|
||||
|
||||
export default function GamepadInputManager() {
|
||||
const {
|
||||
setMode,
|
||||
@@ -57,11 +122,27 @@ export default function GamepadInputManager() {
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
registerInputState,
|
||||
sendSong,
|
||||
setSongNote,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
} = useControlActions();
|
||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { focusChat } = useChatActions();
|
||||
const { isChatFocused } = useChatFocus();
|
||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const sessionMode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const songNote = useControlSelector((control) => control.state.song?.note);
|
||||
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace(
|
||||
'video',
|
||||
VIDEO_SETTINGS_DEFAULTS,
|
||||
);
|
||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||
'gamepad',
|
||||
GAMEPAD_SETTINGS_DEFAULTS,
|
||||
@@ -75,6 +156,12 @@ export default function GamepadInputManager() {
|
||||
const lastAuxSentAtRef = useRef(0);
|
||||
const lastServoAtRef = useRef(0);
|
||||
const lastServoAngleRef = useRef(null);
|
||||
const previousPadRef = useRef(null);
|
||||
const lastConnectedSignatureRef = useRef(null);
|
||||
const lastConnectedInstanceKeyRef = useRef(null);
|
||||
const lastRegisteredSignatureRef = useRef(null);
|
||||
const controllerLockedRef = useRef(false);
|
||||
const waitingForNeutralRef = useRef(false);
|
||||
// The hub subscription is intentionally stable, so this ref is the bridge back to the latest
|
||||
// React values. Rewriting it after each commit is cheaper than tearing down browser gamepad
|
||||
// listeners every time settings, camera state, or control callbacks change.
|
||||
@@ -91,7 +178,10 @@ export default function GamepadInputManager() {
|
||||
latest.saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
if (current.profiles?.[signature]) return current;
|
||||
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
const base = resolveGamepadProfile(
|
||||
current?.defaults?.profile,
|
||||
GAMEPAD_PROFILE_DEFAULT,
|
||||
);
|
||||
const nextProfile = createProfileForPad(padState, base);
|
||||
return {
|
||||
...current,
|
||||
@@ -114,12 +204,19 @@ export default function GamepadInputManager() {
|
||||
const config = latest?.cameraConfig;
|
||||
if (!latest || !config) return;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const cameraMode = calibration?.cameraMode ?? 'absolute';
|
||||
const sensitivity = Math.max(1, Math.min(180, calibration?.cameraSensitivity ?? 60));
|
||||
const cameraMode = calibration?.cameraMode ?? 'velocity';
|
||||
const sensitivity = calibration?.cameraSensitivity ?? 60;
|
||||
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
|
||||
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
|
||||
if (cameraMode === 'velocity') {
|
||||
if (Math.abs(axisValue) <= 0.001) {
|
||||
/* Neutral is the safe synchronization point: no controller motion is being integrated, so
|
||||
an angle changed by another UI can replace our accumulator without causing jitter. */
|
||||
if (typeof latest.cameraAngle === 'number') lastServoAngleRef.current = latest.cameraAngle;
|
||||
lastServoAtRef.current = now;
|
||||
return;
|
||||
}
|
||||
const dt = Math.min(50, now - lastServoAtRef.current || 16);
|
||||
const delta = axisValue * sensitivity * (dt / 1000);
|
||||
if (Math.abs(delta) < 0.01) return;
|
||||
const baseline =
|
||||
typeof lastServoAngleRef.current === 'number'
|
||||
? lastServoAngleRef.current
|
||||
@@ -128,14 +225,12 @@ export default function GamepadInputManager() {
|
||||
: typeof config.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: 0;
|
||||
const nextAngle = baseline + delta;
|
||||
const nextAngle = advanceCameraAngle(baseline, axisValue, sensitivity, dt, { min, max });
|
||||
latest.setServoAngle(nextAngle);
|
||||
lastServoAngleRef.current = nextAngle;
|
||||
lastServoAtRef.current = now;
|
||||
return;
|
||||
}
|
||||
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
|
||||
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
|
||||
const home = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2;
|
||||
const angle =
|
||||
axisValue < 0
|
||||
@@ -153,9 +248,29 @@ export default function GamepadInputManager() {
|
||||
latest.setServoAngle(angle);
|
||||
}, []);
|
||||
|
||||
const activeSignature = useMemo(
|
||||
() => gamepadSettings?.activeSignature ?? null,
|
||||
[gamepadSettings?.activeSignature],
|
||||
const neutralizeController = useCallback((latest) => {
|
||||
/*
|
||||
Every path that makes controller commands unsafe converges here. In particular, held horn
|
||||
and microphone actions need releases just as much as drive and motor axes need zeroes.
|
||||
*/
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
}
|
||||
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
latest.setAuxMotors(ZERO_AUX);
|
||||
}
|
||||
if (buttonStateRef.current.get('hornHonk')) latest.stopHorn();
|
||||
if (buttonStateRef.current.get('micPtt')) latest.setMicPttActive(false);
|
||||
buttonStateRef.current = new Map();
|
||||
reverseStateRef.current = { main: false, side: false };
|
||||
}, []);
|
||||
|
||||
const activeInstanceKey = useMemo(
|
||||
() => gamepadSettings?.activeInstanceKey ?? null,
|
||||
[gamepadSettings?.activeInstanceKey],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -163,83 +278,132 @@ export default function GamepadInputManager() {
|
||||
// after React commits. Updating this ref before paint keeps the stable hub callback aligned
|
||||
// with the newest settings and control actions without resubscribing to the hub.
|
||||
latestRef.current = {
|
||||
activeSignature,
|
||||
activeInstanceKey,
|
||||
adminCanControlLockedLights:
|
||||
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
|
||||
cameraAngle,
|
||||
cameraConfig,
|
||||
dockAssist,
|
||||
focusChat,
|
||||
gamepadSettings,
|
||||
homeAssistant,
|
||||
homeAssistantSetState,
|
||||
isChatFocused,
|
||||
pushAlert,
|
||||
registerInputState,
|
||||
roverId,
|
||||
runMacro,
|
||||
saveGamepadSettings,
|
||||
saveVideoSettings,
|
||||
sendSong,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setDriveVector,
|
||||
setMicPttActive,
|
||||
setMode,
|
||||
setSongNote,
|
||||
setServoAngle,
|
||||
songNote,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
videoColorFilter: videoSettings?.colorFilter ?? VIDEO_SETTINGS_DEFAULTS.colorFilter,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeGamepadHub((hubState) => {
|
||||
const unsubscribe = subscribeGamepadHub((hubState) => {
|
||||
const latest = latestRef.current;
|
||||
if (!latest) return;
|
||||
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
|
||||
const activePad = pickActivePad(hubState.pads, latest.activeInstanceKey);
|
||||
if (!activePad) {
|
||||
// A disconnected controller cannot deliver a final neutral axis sample.
|
||||
// Publish it here so PTZ zoom never depends on the browser doing so.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
}
|
||||
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
latest.setAuxMotors(ZERO_AUX);
|
||||
}
|
||||
buttonStateRef.current = new Map();
|
||||
reverseStateRef.current = { main: false, side: false };
|
||||
// A disconnect cannot provide release samples, so synthesize every required release once.
|
||||
neutralizeController(latest);
|
||||
markControllerDisconnected(lastConnectedSignatureRef.current);
|
||||
previousPadRef.current = null;
|
||||
lastConnectedSignatureRef.current = null;
|
||||
lastConnectedInstanceKeyRef.current = null;
|
||||
lastRegisteredSignatureRef.current = null;
|
||||
controllerLockedRef.current = false;
|
||||
waitingForNeutralRef.current = false;
|
||||
lastDriveSentAtRef.current = 0;
|
||||
lastAuxSentAtRef.current = 0;
|
||||
latest.registerInputState(SOURCE, { connected: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextEntryActive()) {
|
||||
// Entering text blocks gamepad control immediately, including a held
|
||||
// camera axis that otherwise would keep its last PTZ zoom direction.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
if (
|
||||
lastConnectedInstanceKeyRef.current &&
|
||||
lastConnectedInstanceKeyRef.current !== activePad.instanceKey
|
||||
) {
|
||||
/* Browser slots distinguish two identical controllers. Neutralize the old owner before
|
||||
accepting the replacement and require any controls already held on the new pad to be
|
||||
released, preventing a selection change from inheriting drive, horn, or microphone. */
|
||||
neutralizeController(latest);
|
||||
previousPadRef.current = null;
|
||||
lastRegisteredSignatureRef.current = null;
|
||||
waitingForNeutralRef.current = true;
|
||||
}
|
||||
|
||||
if (hasMeaningfulControllerChange(activePad, previousPadRef.current)) {
|
||||
markControllerInputActive(activePad);
|
||||
}
|
||||
previousPadRef.current = activePad;
|
||||
lastConnectedSignatureRef.current = activePad.signature;
|
||||
lastConnectedInstanceKeyRef.current = activePad.instanceKey;
|
||||
|
||||
const controlsBlocked = isTextEntryActive() || isControllerControlLocked();
|
||||
if (controlsBlocked) {
|
||||
/* Configuration and text entry still receive hub snapshots, but they must never leak
|
||||
through to physical rover actions. Only publish/reset on the transition into the lock. */
|
||||
if (!controllerLockedRef.current) {
|
||||
neutralizeController(latest);
|
||||
latest.registerInputState(SOURCE, { connected: true, blocked: true });
|
||||
}
|
||||
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
latest.setAuxMotors(ZERO_AUX);
|
||||
}
|
||||
buttonStateRef.current = new Map();
|
||||
reverseStateRef.current = { main: false, side: false };
|
||||
latest.registerInputState(SOURCE, { connected: true, blocked: true });
|
||||
controllerLockedRef.current = true;
|
||||
waitingForNeutralRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (controllerLockedRef.current) {
|
||||
controllerLockedRef.current = false;
|
||||
latest.registerInputState(SOURCE, { connected: true, blocked: false });
|
||||
}
|
||||
/* A control held while a dialog closes must not become a fresh command. Require a neutral
|
||||
sample before rearming the controller, just like releasing an emergency-stop switch. */
|
||||
if (waitingForNeutralRef.current) {
|
||||
if (!isControllerNeutral(activePad)) return;
|
||||
waitingForNeutralRef.current = false;
|
||||
}
|
||||
|
||||
ensureProfile(activePad);
|
||||
const signature = activePad.signature;
|
||||
const profile =
|
||||
const storedProfile =
|
||||
latest.gamepadSettings?.profiles?.[signature] ??
|
||||
latest.gamepadSettings?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const outputs = computeGamepadOutputs(activePad, profile);
|
||||
|
||||
if (!areVectorsEqual(outputs.driveVector, lastVectorRef.current)) {
|
||||
const driveVector = {
|
||||
...outputs.driveVector,
|
||||
boost: Boolean(outputs.buttons.boostModifier),
|
||||
};
|
||||
if (!areVectorsEqual(driveVector, lastVectorRef.current)) {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const idle = vectorMagnitude(outputs.driveVector) < 0.02;
|
||||
const idle = vectorMagnitude(driveVector) < 0.02;
|
||||
if (idle || now - lastDriveSentAtRef.current >= DRIVE_RATE_MS) {
|
||||
lastVectorRef.current = outputs.driveVector;
|
||||
lastVectorRef.current = driveVector;
|
||||
lastDriveSentAtRef.current = now;
|
||||
latest.setDriveVector(outputs.driveVector, { source: SOURCE });
|
||||
const precisionSpeed = profile.calibration?.precisionSpeed ?? 100;
|
||||
const baseSpeed = profile.calibration?.baseSpeed ?? 500;
|
||||
const turboSpeed = profile.calibration?.turboSpeed ?? 500;
|
||||
latest.setDriveVector(driveVector, {
|
||||
source: SOURCE,
|
||||
speedOptions: outputs.buttons.slowModifier
|
||||
? { baseSpeed: precisionSpeed, boostSpeed: precisionSpeed }
|
||||
: { baseSpeed, boostSpeed: turboSpeed },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,12 +414,30 @@ export default function GamepadInputManager() {
|
||||
const side = reverseStateRef.current.side
|
||||
? -Math.round(sideMagnitude * auxSideScale)
|
||||
: Math.round(sideMagnitude * auxSideScale);
|
||||
/* Digital bindings intentionally override proportional axes. This mirrors keyboard aux
|
||||
precedence exactly while preserving the controller-friendly analog defaults. */
|
||||
let aux = {
|
||||
main: outputs.auxAxis.main !== 0 ? main : 0,
|
||||
side: outputs.auxAxis.side !== 0 ? side : 0,
|
||||
vacuum: outputs.buttons.vacuum ? 127 : 0,
|
||||
main: outputs.buttons.auxMainForward
|
||||
? 127
|
||||
: outputs.buttons.auxMainReverse
|
||||
? -127
|
||||
: outputs.auxAxis.main !== 0
|
||||
? main
|
||||
: 0,
|
||||
side: outputs.buttons.auxSideForward
|
||||
? 127
|
||||
: outputs.buttons.auxSideReverse
|
||||
? -70
|
||||
: outputs.auxAxis.side !== 0
|
||||
? side
|
||||
: 0,
|
||||
vacuum: (outputs.buttons.vacuum || outputs.buttons.auxVacuumFast)
|
||||
? 127
|
||||
: outputs.buttons.auxVacuumSlow
|
||||
? 50
|
||||
: 0,
|
||||
};
|
||||
if (outputs.buttons.allAux) {
|
||||
if (outputs.buttons.allAux || outputs.buttons.auxAllForward) {
|
||||
aux = { main: 127, side: 127, vacuum: 127 };
|
||||
}
|
||||
if (!areAuxEqual(aux, lastAuxRef.current)) {
|
||||
@@ -305,6 +487,67 @@ export default function GamepadInputManager() {
|
||||
handleButtonEdge('laserToggle', false);
|
||||
}
|
||||
|
||||
const hornWasPressed = buttonStateRef.current.get('hornHonk') || false;
|
||||
if (outputs.buttons.hornHonk && handleButtonEdge('hornHonk', true)) {
|
||||
latest.startHorn();
|
||||
} else if (!outputs.buttons.hornHonk) {
|
||||
handleButtonEdge('hornHonk', false);
|
||||
if (hornWasPressed) latest.stopHorn();
|
||||
}
|
||||
|
||||
const micWasPressed = buttonStateRef.current.get('micPtt') || false;
|
||||
if (outputs.buttons.micPtt && handleButtonEdge('micPtt', true)) {
|
||||
latest.setMicPttActive(true);
|
||||
} else if (!outputs.buttons.micPtt) {
|
||||
handleButtonEdge('micPtt', false);
|
||||
if (micWasPressed) latest.setMicPttActive(false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.videoFilterCycle && handleButtonEdge('videoFilterCycle', true)) {
|
||||
const nextFilter = nextVideoFilter(latest.videoColorFilter);
|
||||
latest.saveVideoSettings((current) => ({ ...(current ?? {}), colorFilter: nextFilter }));
|
||||
latest.pushAlert({
|
||||
id: 'video-filter-active',
|
||||
title: 'Video filter',
|
||||
message: `Rover video filter: ${nextFilter}`,
|
||||
color: '#38bdf8',
|
||||
lifetimeMs: 1600,
|
||||
});
|
||||
} else if (!outputs.buttons.videoFilterCycle) {
|
||||
handleButtonEdge('videoFilterCycle', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.chatFocus && handleButtonEdge('chatFocus', true)) {
|
||||
if (!latest.isChatFocused) latest.focusChat();
|
||||
} else if (!outputs.buttons.chatFocus) {
|
||||
handleButtonEdge('chatFocus', false);
|
||||
}
|
||||
|
||||
/* Song directions share identical edge and wrap behavior; the table keeps the two actions
|
||||
symmetric and prevents one direction from silently diverging during later changes. */
|
||||
for (const [actionId, direction] of [['songNoteUp', 1], ['songNoteDown', -1]]) {
|
||||
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
|
||||
const [minNote, maxNote] = SONG_NOTE_RANGE;
|
||||
const currentNote = typeof latest.songNote === 'number' ? latest.songNote : SONG_DEFAULT_NOTE;
|
||||
const candidate = currentNote + direction;
|
||||
const nextNote = candidate > maxNote ? minNote : candidate < minNote ? maxNote : candidate;
|
||||
latest.setSongNote(nextNote);
|
||||
latest.sendSong([{ note: nextNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
|
||||
} else if (!outputs.buttons[actionId]) {
|
||||
handleButtonEdge(actionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
/* Room-control cycling differs only by target state, so both bindings use the same policy
|
||||
checks and ordered entity selection. */
|
||||
for (const [actionId, targetState] of [['homeAssistantOn', 'on'], ['homeAssistantOff', 'off']]) {
|
||||
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
|
||||
cycleHomeAssistant(latest, targetState);
|
||||
} else if (!outputs.buttons[actionId]) {
|
||||
handleButtonEdge(actionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
PTZ zoom consumes the live signed gamepad axis, including its zero
|
||||
position, so releasing the stick is an explicit stop instead of merely
|
||||
@@ -312,24 +555,33 @@ export default function GamepadInputManager() {
|
||||
here and continue through their established absolute/velocity mapping.
|
||||
*/
|
||||
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
|
||||
if (!handledAsPtzZoom && Math.abs(outputs.cameraAxis) > 0.001) {
|
||||
if (
|
||||
!handledAsPtzZoom &&
|
||||
(profile.calibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
|
||||
) {
|
||||
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
||||
}
|
||||
|
||||
latest.registerInputState(SOURCE, {
|
||||
connected: true,
|
||||
signature,
|
||||
id: activePad.id,
|
||||
index: activePad.index,
|
||||
axes: activePad.axes,
|
||||
buttons: activePad.buttons,
|
||||
drive: outputs.driveVector,
|
||||
aux,
|
||||
cameraAxis: outputs.cameraAxis,
|
||||
bindings: outputs.sources,
|
||||
});
|
||||
/* Raw values remain in the dedicated hub used by diagnostics. The shared reducer only
|
||||
needs connection identity, which avoids forcing the entire provider through 60 updates/s. */
|
||||
if (lastRegisteredSignatureRef.current !== signature) {
|
||||
lastRegisteredSignatureRef.current = signature;
|
||||
latest.registerInputState(SOURCE, {
|
||||
connected: true,
|
||||
blocked: false,
|
||||
signature,
|
||||
id: activePad.id,
|
||||
index: activePad.index,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [ensureProfile, handleButtonEdge, handleCameraAxis]);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
const latest = latestRef.current;
|
||||
if (latest) neutralizeController(latest);
|
||||
markControllerDisconnected(lastConnectedSignatureRef.current);
|
||||
};
|
||||
}, [ensureProfile, handleButtonEdge, handleCameraAxis, neutralizeController]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { markKeyboardInputActive } from './controllerRuntime.js';
|
||||
import { isTextInputElement } from './inputFocusUtils.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
@@ -428,6 +429,13 @@ export default function KeyboardInputManager() {
|
||||
const tokens = tokensForEvent(event);
|
||||
if (tokens.length === 0) return;
|
||||
const tokenSet = new Set(tokens);
|
||||
/*
|
||||
Shortcut prompts follow the last meaningful control device, not arbitrary typing. Only a
|
||||
key that participates in the configured control map claims keyboard modality.
|
||||
*/
|
||||
if (tokens.some((token) => latest.actionTokens.has(token))) {
|
||||
markKeyboardInputActive();
|
||||
}
|
||||
if (bindingActive(latest.keymap.chatFocus, tokenSet)) {
|
||||
event.preventDefault();
|
||||
resetAll();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Controller Prompt Labels
|
||||
// Purpose: Converts persisted controller bindings into compact prompts for the connected hardware.
|
||||
// Scope: Delegates hardware identification and standard button naming to gamepad-helper while
|
||||
// keeping rover action aliases and compact presentation local to the controller input layer.
|
||||
import GamepadHelper from '@lizardbyte/gamepad-helper/src/js/gamepad-helper.js';
|
||||
|
||||
const gamepadHelper = new GamepadHelper();
|
||||
|
||||
const ACTION_ALIASES = {
|
||||
driveForward: { bindingId: 'drive', direction: 'up' },
|
||||
driveBackward: { bindingId: 'drive', direction: 'down' },
|
||||
driveLeft: { bindingId: 'drive', direction: 'left' },
|
||||
driveRight: { bindingId: 'drive', direction: 'right' },
|
||||
cameraUp: { bindingId: 'cameraTilt', direction: 'up' },
|
||||
cameraDown: { bindingId: 'cameraTilt', direction: 'down' },
|
||||
auxMainForward: { bindingId: 'mainBrush', direction: 'forward' },
|
||||
auxMainReverse: { bindingId: 'mainBrush', direction: 'reverse' },
|
||||
auxSideForward: { bindingId: 'sideBrush', direction: 'forward' },
|
||||
auxSideReverse: { bindingId: 'sideBrush', direction: 'reverse' },
|
||||
auxVacuumFast: { bindingId: 'vacuum' },
|
||||
auxVacuumSlow: { bindingId: 'vacuum' },
|
||||
auxAllForward: { bindingId: 'allAux' },
|
||||
};
|
||||
|
||||
const DIRECTION_GLYPHS = {
|
||||
up: '↑',
|
||||
down: '↓',
|
||||
left: '←',
|
||||
right: '→',
|
||||
forward: '+',
|
||||
reverse: '−',
|
||||
};
|
||||
|
||||
const COMPACT_BUTTON_NAMES = {
|
||||
DUp: 'D↑',
|
||||
DDown: 'D↓',
|
||||
DLeft: 'D←',
|
||||
DRight: 'D→',
|
||||
TouchPad: 'Touchpad',
|
||||
};
|
||||
|
||||
function controllerType(controller, promptStyle) {
|
||||
/* Manual prompt selection is a direct library controller type, not a model-name imitation.
|
||||
Automatic mode gives the complete browser ID to gamepad-helper unchanged; in particular,
|
||||
its vendor/product lookup directly recognizes Linux's 054c-0ce6 DualSense identifier. */
|
||||
if (promptStyle && promptStyle !== 'auto') return promptStyle;
|
||||
return gamepadHelper.detectControllerType(controller?.id ?? '');
|
||||
}
|
||||
|
||||
export function describeController(controller) {
|
||||
const info = gamepadHelper.getGamepadInfo(controller?.id ?? '');
|
||||
return {
|
||||
model: info.type,
|
||||
brand: info.type === gamepadHelper.CONTROLLER_TYPES.PLAYSTATION ? 'Sony' : null,
|
||||
description: info.name,
|
||||
};
|
||||
}
|
||||
|
||||
function compactButtonName(source, type) {
|
||||
const name = gamepadHelper.getButtonName(type, source.index);
|
||||
return COMPACT_BUTTON_NAMES[name] ?? name;
|
||||
}
|
||||
|
||||
function compactAxisName(source) {
|
||||
/* Standard browser mappings place sticks in adjacent pairs. Showing the stick instead of its
|
||||
raw component keeps prompts short; the action and optional arrow already convey the axis. */
|
||||
if (source.index === 0 || source.index === 1) return 'LS';
|
||||
if (source.index === 2 || source.index === 3) return 'RS';
|
||||
return `A${source.index}`;
|
||||
}
|
||||
|
||||
function compactAxisPairName(source) {
|
||||
if (source.x === 0 && source.y === 1) return 'LS';
|
||||
if (source.x === 2 && source.y === 3) return 'RS';
|
||||
return `A${source.x}/${source.y}`;
|
||||
}
|
||||
|
||||
function compactSourceName(source, type) {
|
||||
if (!source) return '—';
|
||||
if (source.kind === 'chord') {
|
||||
return (source.inputs ?? []).map((input) => compactSourceName(input, type)).join('+');
|
||||
}
|
||||
if (source.kind === 'axisPair') return compactAxisPairName(source);
|
||||
if (source.kind === 'button' || source.kind === 'buttonAxis') {
|
||||
return compactButtonName(source, type);
|
||||
}
|
||||
if (source.kind === 'axis' || source.kind === 'axisButton') {
|
||||
return compactAxisName(source);
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
export function bindingForControllerAction(profile, actionId) {
|
||||
const direct = profile?.bindings?.[actionId];
|
||||
if (direct?.sources?.length) return { binding: direct, direction: null };
|
||||
const alias = ACTION_ALIASES[actionId];
|
||||
if (!alias) return { binding: direct ?? null, direction: null };
|
||||
return {
|
||||
binding: profile?.bindings?.[alias.bindingId] ?? null,
|
||||
direction: alias.direction ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatControllerBinding(profile, actionId, controller) {
|
||||
const { binding, direction } = bindingForControllerAction(profile, actionId);
|
||||
const source = binding?.sources?.[0];
|
||||
if (!source) return '—';
|
||||
const type = controllerType(controller, profile?.promptStyle);
|
||||
const label = compactSourceName(source, type);
|
||||
const directionLabel = direction ? DIRECTION_GLYPHS[direction] : null;
|
||||
return directionLabel ? `${label} ${directionLabel}` : label;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Controller Prompt Label Tests
|
||||
// Purpose: Protect the adapter between persisted rover actions and the third-party controller
|
||||
// model database, including manual prompt families and directional fallback aliases.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||
import { formatControllerBinding } from './controllerLabels.js';
|
||||
|
||||
test('uses the manually selected PlayStation button family', () => {
|
||||
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation-dual-sense' };
|
||||
const label = formatControllerBinding(profile, 'vacuum', {
|
||||
id: 'Controller hidden by browser privacy mode',
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
assert.equal(label, 'X');
|
||||
});
|
||||
|
||||
test('falls back from a keyboard direction action to its controller axis', () => {
|
||||
const label = formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'driveForward', {
|
||||
id: 'Xbox Wireless Controller',
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
assert.equal(label, 'Left stick ↑');
|
||||
});
|
||||
|
||||
test('a direct digital aux binding takes priority over its analog fallback', () => {
|
||||
const profile = {
|
||||
...GAMEPAD_PROFILE_DEFAULT,
|
||||
bindings: {
|
||||
...GAMEPAD_PROFILE_DEFAULT.bindings,
|
||||
auxMainReverse: { kind: 'button', sources: [{ kind: 'button', index: 15 }] },
|
||||
},
|
||||
};
|
||||
const label = formatControllerBinding(profile, 'auxMainReverse', {
|
||||
id: 'Xbox Wireless Controller',
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
assert.equal(label, 'D-pad right');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Controller Runtime Coordination
|
||||
// Purpose: Shares controller-only runtime facts without pushing animation-frame data through
|
||||
// React's application-wide control reducer.
|
||||
// Scope: Owns prompt modality, the last controller used, and temporary command suppression while
|
||||
// a controller is being configured. It does not send rover commands or interpret bindings.
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const listeners = new Set();
|
||||
const controlLocks = new Set();
|
||||
|
||||
let snapshot = {
|
||||
inputMethod: 'keyboard',
|
||||
controller: null,
|
||||
};
|
||||
|
||||
function publish(nextSnapshot) {
|
||||
if (
|
||||
nextSnapshot.inputMethod === snapshot.inputMethod &&
|
||||
nextSnapshot.controller?.signature === snapshot.controller?.signature &&
|
||||
nextSnapshot.controller?.id === snapshot.controller?.id &&
|
||||
nextSnapshot.controller?.mapping === snapshot.controller?.mapping
|
||||
) {
|
||||
return;
|
||||
}
|
||||
snapshot = nextSnapshot;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function markKeyboardInputActive() {
|
||||
publish({ ...snapshot, inputMethod: 'keyboard' });
|
||||
}
|
||||
|
||||
export function markControllerInputActive(controller) {
|
||||
if (!controller) return;
|
||||
publish({
|
||||
inputMethod: 'controller',
|
||||
controller: {
|
||||
signature: controller.signature ?? null,
|
||||
id: controller.id ?? 'Unknown controller',
|
||||
mapping: controller.mapping ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function markControllerDisconnected(signature) {
|
||||
if (!snapshot.controller || snapshot.controller.signature !== signature) return;
|
||||
publish({ inputMethod: 'keyboard', controller: null });
|
||||
}
|
||||
|
||||
export function acquireControllerControlLock(reason = 'controller-configuration') {
|
||||
/*
|
||||
A tokenized lock is used instead of one boolean because a capture dialog and its parent
|
||||
settings surface can overlap during React cleanup. Releasing either owner must not briefly
|
||||
re-enable commands while the other owner still expects input to be diagnostic-only.
|
||||
*/
|
||||
const token = Symbol(reason);
|
||||
controlLocks.add(token);
|
||||
return () => controlLocks.delete(token);
|
||||
}
|
||||
|
||||
export function isControllerControlLocked() {
|
||||
return controlLocks.size > 0;
|
||||
}
|
||||
|
||||
export function subscribeControllerRuntime(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getControllerRuntimeSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function useControllerRuntime() {
|
||||
return useSyncExternalStore(
|
||||
subscribeControllerRuntime,
|
||||
getControllerRuntimeSnapshot,
|
||||
getControllerRuntimeSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,39 @@
|
||||
// Purpose: Defines default gamepad axis/button-to-action mappings and lookup helpers. Scope: Supplies binding metadata for gamepad input manager and settings UI.
|
||||
const CURVE_EXPO = 1.6;
|
||||
|
||||
/*
|
||||
Binary actions share one resolver so the runtime, settings UI, diagnostics, and adaptive
|
||||
prompts all operate on the same complete action set. Adding an action here is intentionally
|
||||
controller-local and does not add controller concepts to the shared command pipeline.
|
||||
*/
|
||||
export const GAMEPAD_BUTTON_ACTION_IDS = [
|
||||
'vacuum',
|
||||
'allAux',
|
||||
'mainReverse',
|
||||
'sideReverse',
|
||||
'driveMacro',
|
||||
'dockMacro',
|
||||
'headlightToggle',
|
||||
'laserToggle',
|
||||
'boostModifier',
|
||||
'slowModifier',
|
||||
'hornHonk',
|
||||
'micPtt',
|
||||
'videoFilterCycle',
|
||||
'chatFocus',
|
||||
'songNoteUp',
|
||||
'songNoteDown',
|
||||
'homeAssistantOn',
|
||||
'homeAssistantOff',
|
||||
'auxMainForward',
|
||||
'auxMainReverse',
|
||||
'auxSideForward',
|
||||
'auxSideReverse',
|
||||
'auxVacuumFast',
|
||||
'auxVacuumSlow',
|
||||
'auxAllForward',
|
||||
];
|
||||
|
||||
export function getPadSignature(pad) {
|
||||
if (!pad) return 'unknown::none::0::0';
|
||||
const id = pad.id || 'unknown';
|
||||
@@ -26,6 +59,60 @@ export function createProfileForPad(pad, baseProfile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function resolveGamepadProfile(profile, defaults) {
|
||||
/*
|
||||
Profiles are persisted independently per controller. Merge at the binding and calibration
|
||||
levels so adding a newly supported logical action immediately gives existing controllers a
|
||||
usable default without overwriting any binding the user deliberately customized.
|
||||
*/
|
||||
const base = defaults ?? {};
|
||||
const current = profile ?? {};
|
||||
const requiresBehaviorUpgrade = current.behaviorVersion !== base.behaviorVersion;
|
||||
return {
|
||||
...base,
|
||||
...current,
|
||||
behaviorVersion: base.behaviorVersion,
|
||||
calibration: {
|
||||
...(base.calibration ?? {}),
|
||||
...(current.calibration ?? {}),
|
||||
/* Version one introduced an absolute camera default and a 250 wheel-speed ceiling. Apply
|
||||
the corrected behavior to persisted profiles once while preserving every user binding. */
|
||||
...(requiresBehaviorUpgrade
|
||||
? {
|
||||
cameraMode: base.calibration?.cameraMode,
|
||||
baseSpeed: base.calibration?.baseSpeed,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
bindings: {
|
||||
...(base.bindings ?? {}),
|
||||
...(current.bindings ?? {}),
|
||||
/* The old defaults listed unrelated live axes as fallbacks. Since those indices still exist
|
||||
on a standard pad, they were not real fallbacks and could make one stick own two actions.
|
||||
Reset only these three version-one defaults; every explicitly digital binding survives. */
|
||||
...(requiresBehaviorUpgrade
|
||||
? {
|
||||
cameraTilt: base.bindings?.cameraTilt,
|
||||
mainBrush: base.bindings?.mainBrush,
|
||||
sideBrush: base.bindings?.sideBrush,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceCameraAngle(currentAngle, axisValue, sensitivity, elapsedMs, limits) {
|
||||
/* Velocity camera state must never accumulate beyond the physical servo limits. Otherwise a
|
||||
long hold at an endpoint creates an invisible overshoot that has to unwind before reversing. */
|
||||
const min = Number.isFinite(limits?.min) ? limits.min : -45;
|
||||
const max = Number.isFinite(limits?.max) ? limits.max : 45;
|
||||
const baseline = Number.isFinite(currentAngle) ? currentAngle : (min + max) / 2;
|
||||
const safeElapsedMs = Math.max(0, Math.min(50, Number(elapsedMs) || 0));
|
||||
const degreesPerSecond = Math.max(1, Math.min(180, Number(sensitivity) || 60));
|
||||
const candidate = baseline + axisValue * degreesPerSecond * (safeElapsedMs / 1000);
|
||||
return Math.max(min, Math.min(max, candidate));
|
||||
}
|
||||
|
||||
function clampUnit(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
@@ -104,27 +191,41 @@ function resolveAxisPairSource(padState, sources = []) {
|
||||
}
|
||||
|
||||
function resolveButtonSource(padState, sources = []) {
|
||||
let firstReadableSource = null;
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
if (source.kind === 'chord') {
|
||||
const inputs = Array.isArray(source.inputs) ? source.inputs : [];
|
||||
if (inputs.length === 0) continue;
|
||||
const pressed = inputs.every((input) => resolveButtonSource(padState, [input]).pressed);
|
||||
if (pressed) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
continue;
|
||||
}
|
||||
if (source.kind === 'button') {
|
||||
const btn = readButton(padState, source.index);
|
||||
if (!btn) continue;
|
||||
return { pressed: btn.pressed, source };
|
||||
if (btn.pressed) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
continue;
|
||||
}
|
||||
if (source.kind === 'axisButton') {
|
||||
const value = readAxis(padState, source.index);
|
||||
if (value === null) continue;
|
||||
const direction = source.direction || 1;
|
||||
const threshold = typeof source.threshold === 'number' ? source.threshold : 0.6;
|
||||
return { pressed: value * direction > threshold, source };
|
||||
if (value * direction > threshold) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
continue;
|
||||
}
|
||||
if (source.kind === 'buttonAxis') {
|
||||
const btn = readButton(padState, source.index);
|
||||
if (!btn) continue;
|
||||
return { pressed: btn.value > 0.5, source };
|
||||
if (btn.value > 0.5) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
}
|
||||
}
|
||||
return { pressed: false, source: null };
|
||||
return { pressed: false, source: firstReadableSource };
|
||||
}
|
||||
|
||||
export function computeGamepadOutputs(padState, profile) {
|
||||
@@ -157,42 +258,28 @@ export function computeGamepadOutputs(padState, profile) {
|
||||
let sideAxis = applyAxisDeadzone(clampUnit(sideSource.value), auxDeadzone);
|
||||
sideAxis = applyCurve(sideAxis, calibration.auxCurve);
|
||||
|
||||
const vacuumSource = resolveButtonSource(padState, bindings.vacuum?.sources);
|
||||
const allAuxSource = resolveButtonSource(padState, bindings.allAux?.sources);
|
||||
const mainReverseSource = resolveButtonSource(padState, bindings.mainReverse?.sources);
|
||||
const sideReverseSource = resolveButtonSource(padState, bindings.sideReverse?.sources);
|
||||
const driveMacroSource = resolveButtonSource(padState, bindings.driveMacro?.sources);
|
||||
const dockMacroSource = resolveButtonSource(padState, bindings.dockMacro?.sources);
|
||||
const headlightSource = resolveButtonSource(padState, bindings.headlightToggle?.sources);
|
||||
const laserSource = resolveButtonSource(padState, bindings.laserToggle?.sources);
|
||||
const buttonOutputs = Object.fromEntries(
|
||||
GAMEPAD_BUTTON_ACTION_IDS.map((actionId) => [
|
||||
actionId,
|
||||
resolveButtonSource(padState, bindings[actionId]?.sources),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
driveVector: { x: driveX, y: driveY, boost: false },
|
||||
cameraAxis,
|
||||
auxAxis: { main: mainAxis, side: sideAxis },
|
||||
buttons: {
|
||||
vacuum: vacuumSource.pressed,
|
||||
allAux: allAuxSource.pressed,
|
||||
mainReverse: mainReverseSource.pressed,
|
||||
sideReverse: sideReverseSource.pressed,
|
||||
driveMacro: driveMacroSource.pressed,
|
||||
dockMacro: dockMacroSource.pressed,
|
||||
headlightToggle: headlightSource.pressed,
|
||||
laserToggle: laserSource.pressed,
|
||||
},
|
||||
buttons: Object.fromEntries(
|
||||
Object.entries(buttonOutputs).map(([actionId, output]) => [actionId, output.pressed]),
|
||||
),
|
||||
sources: {
|
||||
drive: driveSource.source,
|
||||
cameraTilt: cameraSource.source,
|
||||
mainBrush: mainSource.source,
|
||||
sideBrush: sideSource.source,
|
||||
vacuum: vacuumSource.source,
|
||||
allAux: allAuxSource.source,
|
||||
mainReverse: mainReverseSource.source,
|
||||
sideReverse: sideReverseSource.source,
|
||||
driveMacro: driveMacroSource.source,
|
||||
dockMacro: dockMacroSource.source,
|
||||
headlightToggle: headlightSource.source,
|
||||
laserToggle: laserSource.source,
|
||||
...Object.fromEntries(
|
||||
Object.entries(buttonOutputs).map(([actionId, output]) => [actionId, output.source]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Gamepad Binding Tests
|
||||
// Purpose: Locks down the safety-critical conversion from browser values to logical actions.
|
||||
// Scope: Exercises pure binding behavior without mounting React or opening a real controller.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { computeGamepadOutputs, resolveGamepadProfile } from './gamepadBindings.js';
|
||||
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||
|
||||
function pad({ axes = [0, 0, 0, 0], pressed = [], values = {} } = {}) {
|
||||
return {
|
||||
axes,
|
||||
buttons: Array.from({ length: 18 }, (_, index) => ({
|
||||
pressed: pressed.includes(index),
|
||||
value: values[index] ?? (pressed.includes(index) ? 1 : 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
test('radial drive deadzone removes drift and rescales real movement', () => {
|
||||
const idle = computeGamepadOutputs(pad({ axes: [0.1, -0.1, 0, 0] }), GAMEPAD_PROFILE_DEFAULT);
|
||||
assert.deepEqual(idle.driveVector, { x: 0, y: 0, boost: false });
|
||||
|
||||
const moving = computeGamepadOutputs(pad({ axes: [0, -0.59, 0, 0] }), GAMEPAD_PROFILE_DEFAULT);
|
||||
assert.equal(moving.driveVector.x, 0);
|
||||
assert.ok(moving.driveVector.y > 0.49 && moving.driveVector.y < 0.51);
|
||||
});
|
||||
|
||||
test('button chords require every constituent input', () => {
|
||||
const profile = resolveGamepadProfile({
|
||||
bindings: {
|
||||
hornHonk: {
|
||||
kind: 'button',
|
||||
sources: [{
|
||||
kind: 'chord',
|
||||
inputs: [{ kind: 'button', index: 4 }, { kind: 'button', index: 0 }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
}, GAMEPAD_PROFILE_DEFAULT);
|
||||
|
||||
assert.equal(computeGamepadOutputs(pad({ pressed: [4] }), profile).buttons.hornHonk, false);
|
||||
assert.equal(computeGamepadOutputs(pad({ pressed: [4, 0] }), profile).buttons.hornHonk, true);
|
||||
});
|
||||
|
||||
test('multiple button sources behave as alternatives instead of first-source-only fallbacks', () => {
|
||||
const profile = resolveGamepadProfile({
|
||||
bindings: {
|
||||
laserToggle: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 2 }, { kind: 'button', index: 7 }],
|
||||
},
|
||||
},
|
||||
}, GAMEPAD_PROFILE_DEFAULT);
|
||||
|
||||
assert.equal(computeGamepadOutputs(pad({ pressed: [7] }), profile).buttons.laserToggle, true);
|
||||
});
|
||||
|
||||
test('profile resolution adds new actions without overwriting customized bindings', () => {
|
||||
const customDrive = {
|
||||
kind: 'axisPair',
|
||||
sources: [{ kind: 'axisPair', x: 2, y: 3, invertX: true, invertY: false }],
|
||||
};
|
||||
const resolved = resolveGamepadProfile({ bindings: { drive: customDrive } }, GAMEPAD_PROFILE_DEFAULT);
|
||||
|
||||
assert.deepEqual(resolved.bindings.drive, customDrive);
|
||||
assert.ok(resolved.bindings.hornHonk);
|
||||
});
|
||||
@@ -5,9 +5,10 @@ import { getPadSignature } from './gamepadBindings.js';
|
||||
|
||||
const listeners = new Set();
|
||||
let rafId = null;
|
||||
let lastState = { pads: [], timestamp: 0 };
|
||||
let lastState = { pads: [], timestamp: 0, supported: true, error: null };
|
||||
let hasDeviceListeners = false;
|
||||
let deviceChangeHandler = null;
|
||||
let lastReadError = null;
|
||||
|
||||
function hasConnectedPads() {
|
||||
return readGamepads().some((pad) => pad?.connected !== false);
|
||||
@@ -15,14 +16,24 @@ function hasConnectedPads() {
|
||||
|
||||
function readGamepads() {
|
||||
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
|
||||
lastReadError = new Error('This browser does not support the Gamepad API.');
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const pads = navigator.getGamepads();
|
||||
lastReadError = null;
|
||||
if (!pads) return [];
|
||||
return Array.from(pads).filter(Boolean);
|
||||
} catch (error) {
|
||||
/* Permissions Policy can make getGamepads throw instead of returning an empty list. Preserve
|
||||
that distinction so the setup UI can explain why reconnecting hardware will not help. */
|
||||
lastReadError = error instanceof Error ? error : new Error(String(error));
|
||||
return [];
|
||||
}
|
||||
const pads = navigator.getGamepads();
|
||||
if (!pads) return [];
|
||||
return Array.from(pads).filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPadState(pad) {
|
||||
const signature = getPadSignature(pad);
|
||||
return {
|
||||
index: pad.index,
|
||||
id: pad.id,
|
||||
@@ -34,7 +45,8 @@ function buildPadState(pad) {
|
||||
pressed: Boolean(btn?.pressed),
|
||||
value: typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0,
|
||||
})),
|
||||
signature: getPadSignature(pad),
|
||||
signature,
|
||||
instanceKey: `${signature}::slot-${pad.index}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +55,8 @@ function updateState() {
|
||||
lastState = {
|
||||
pads,
|
||||
timestamp: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||
supported: typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function',
|
||||
error: lastReadError?.message ?? null,
|
||||
};
|
||||
listeners.forEach((listener) => listener(lastState));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user