This commit is contained in:
legop3
2026-09-12 20:52:34 -04:00
parent ba5c1c5d25
commit 8e96c3cdae
40 changed files with 1725 additions and 420 deletions
+320 -68
View File
@@ -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,
);
}
+117 -30
View File
@@ -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);
});
+19 -5
View File
@@ -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));
}