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
+290 -11
View File
@@ -8,6 +8,7 @@
"name": "webui",
"version": "0.0.0",
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
@@ -34,7 +35,8 @@
"postcss": "^8.5.6",
"socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14",
"vite": "^7.2.2"
"vite": "^7.2.2",
"vitest": "^5.0.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1034,9 +1036,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -1051,6 +1053,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lizardbyte/gamepad-helper": {
"version": "2026.816.4539",
"resolved": "https://registry.npmjs.org/@lizardbyte/gamepad-helper/-/gamepad-helper-2026.816.4539.tgz",
"integrity": "sha512-Tncd9+MEUOU9JpDnJIeFEDlUure8CGItLHbcr08i14uylUtx20oqWnTR+ZGmVITeGjdqW9+a43FSMJUiK0c4LA==",
"license": "MIT",
"funding": {
"url": "https://app.lizardbyte.dev"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1483,6 +1494,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1550,6 +1579,44 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1667,6 +1734,16 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/autoprefixer": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
@@ -1834,6 +1911,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2061,6 +2148,13 @@
"node": ">=10.0.0"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2300,6 +2394,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2310,6 +2414,16 @@
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2915,6 +3029,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
"integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3073,6 +3197,20 @@
"node": ">= 6"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3208,9 +3346,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3727,6 +3865,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -3780,6 +3925,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -4013,15 +4172,35 @@
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -4201,6 +4380,89 @@
}
}
},
"node_modules/vitest": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/web-haptics": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz",
@@ -4243,6 +4505,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+4 -1
View File
@@ -7,9 +7,11 @@
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"build": "vite build",
"lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
"preview": "vite preview"
},
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
@@ -36,6 +38,7 @@
"postcss": "^8.5.6",
"socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14",
"vite": "^7.2.2"
"vite": "^7.2.2",
"vitest": "^5.0.0"
}
}
+3 -3
View File
@@ -68,8 +68,8 @@ export default function CardFrame({
? { borderColor: '#008a35' }
: accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
// backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.1)} 100%)`,
// backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.2)} 100%)`,
// backgroundImage: `background-color: ${rgba(accentRgb, 0.2)}`
}
: undefined;
@@ -100,7 +100,7 @@ export default function CardFrame({
>
<div className="flex min-w-0 items-center gap-0.5">
{title ? (
<p className={cx('m-0 text-[0.78rem] font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
<p className={cx('m-0 font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
{title}
</p>
) : null}
@@ -0,0 +1,9 @@
// Adaptive Control Hint
// Purpose: Renders the binding for a logical action using the user's most recently used input type.
// Scope: Keeps the render component separate from its hook so React fast refresh can safely
// replace this module without treating a non-component export as component state.
import { useControlHintLabel } from './useControlHintLabel.js';
export default function ControlHint({ actionId }) {
return <>{useControlHintLabel(actionId)}</>;
}
@@ -0,0 +1,31 @@
// Adaptive Control Hint Label Hook
// Purpose: Resolves a logical action to the keyboard or controller label appropriate for the
// operator's most recently used input device.
// Scope: Reads control/settings state only; it never captures input or dispatches rover commands.
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
import { formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
import { resolveGamepadProfile } from '../../controls/inputs/gamepadBindings.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
export function useControlHintLabel(actionId) {
const keyValue = useControlSelector((control) => control.state.keymap?.[actionId]?.[0]);
const runtime = useControllerRuntime();
const { value: gamepadSettings } = useSettingsNamespace('gamepad', GAMEPAD_SETTINGS_DEFAULTS);
if (runtime.inputMethod !== 'controller' || !runtime.controller) {
return formatKeyLabel(keyValue);
}
/* Profiles remain keyed by reusable hardware signature, while runtime controller selection is
instance-specific. This lets two identical connected pads share a mapping without losing the
browser slot used to decide which one currently owns control. */
const storedProfile =
gamepadSettings?.profiles?.[runtime.controller.signature] ??
gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT;
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
return formatControllerBinding(profile, actionId, runtime.controller);
}
@@ -6,7 +6,7 @@ import '../MobileControls/mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -90,7 +90,6 @@ export default function DriveDockAction({
}) {
const isMobile = layout === 'mobile';
const roverId = useControlSelector((control) => control.state.roverId);
const keymap = useControlSelector((control) => control.state.keymap);
const actions = useControlActions();
const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
@@ -104,8 +103,8 @@ export default function DriveDockAction({
const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null;
const driveKeyLabel = formatKeyLabel(keymap?.driveMacro?.[0]);
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
const driveKeyLabel = <ControlHint actionId="driveMacro" />;
const dockKeyLabel = <ControlHint actionId="dockMacro" />;
const dockInstructions = {
summary: 'Use assist mode to manually line up with the dock.',
@@ -7,13 +7,15 @@ import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settin
import {
computeGamepadOutputs,
createProfileForPad,
resolveGamepadProfile,
} from '../../controls/inputs/gamepadBindings.js';
import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js';
import { acquireControllerControlLock } from '../../controls/inputs/controllerRuntime.js';
import { describeController, formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
import CardFrame from '../CardFrame/index.jsx';
import SliderField from './SliderField.jsx';
import { ACTIONS, NUMBER_FORMAT } from './constants.js';
import {
formatSource,
groupActions,
pickActivePad,
snapshotBaseline,
@@ -26,21 +28,45 @@ function SettingsGroupLabel({ children }) {
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
}
function CurveField({ label, value, onChange }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="linear">Linear</option>
<option value="expo">Fine center control</option>
</select>
</div>
</label>
);
}
function MappingRow({
action,
source,
sourceLabel,
liveValue,
isCapturing,
onClear,
onCapture,
onInvert,
disabled,
}) {
// Mapping rows are constrained to a readable width so the source text and buttons remain
// visually connected. Buttons wrap on very narrow panes instead of forcing tiny text.
return (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
<div className="min-w-0">
<p className="font-semibold leading-snug text-white">{action.label}</p>
<p className="mt-0.5 text-xs leading-snug text-white">{formatSource(source)}</p>
<div className="flex items-center gap-1">
<p className="font-semibold leading-snug text-white">{action.label}</p>
<span className={`h-1.5 w-1.5 rounded-full ${liveValue ? 'bg-emerald-400' : 'bg-neutral-600'}`} aria-hidden="true" />
</div>
<p className="mt-0.5 text-xs leading-snug text-white">{sourceLabel}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-1 max-[520px]:justify-start">
{/* Axis-pair controls expose independent inversion because stick X/Y directions often
@@ -50,7 +76,7 @@ function MappingRow({
<>
<button
type="button"
disabled={!source}
disabled={disabled || !source}
onClick={() => onInvert(action, 'invertX')}
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
>
@@ -58,7 +84,7 @@ function MappingRow({
</button>
<button
type="button"
disabled={!source}
disabled={disabled || !source}
onClick={() => onInvert(action, 'invertY')}
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
>
@@ -71,7 +97,7 @@ function MappingRow({
{action.kind === 'axis' && (
<button
type="button"
disabled={!source}
disabled={disabled || !source}
onClick={() => onInvert(action)}
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
>
@@ -80,17 +106,18 @@ function MappingRow({
)}
{/* Clear and Capture are always present because they are the primary row actions. They
wrap with the inversion controls on narrow panes instead of shrinking text. */}
<button type="button" onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs">
<button type="button" disabled={disabled} onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
Clear
</button>
<button
type="button"
disabled={disabled}
onClick={() => onCapture(action)}
className={`${
isCapturing
? 'rounded-md bg-emerald-500 px-1 py-0.5 text-emerald-950 hover:bg-emerald-400'
: 'button-dark px-1 py-0.5'
} text-xs font-medium`}
} text-xs font-medium disabled:opacity-50`}
>
{isCapturing ? 'Waiting...' : 'Capture'}
</button>
@@ -106,24 +133,38 @@ export default function GamepadMappingSettings() {
GAMEPAD_SETTINGS_DEFAULTS,
);
const [captureAction, setCaptureAction] = useState(null);
const [actionFilter, setActionFilter] = useState('');
const baselineRef = useRef(null);
const grouped = useMemo(() => groupActions(ACTIONS), []);
const captureCandidateRef = useRef(null);
const grouped = useMemo(() => {
const query = actionFilter.trim().toLowerCase();
const visibleActions = query
? ACTIONS.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
: ACTIONS;
return groupActions(visibleActions);
}, [actionFilter]);
useEffect(() => {
/* The Controller tab is a diagnostic surface. Locking for its entire mounted lifetime makes
calibration and casual input testing safe, not only the brief moment a binding is captured. */
return acquireControllerControlLock('controller-settings');
}, []);
const activePad = useMemo(
() => pickActivePad(hubState.pads, gamepadSettings.activeSignature),
[hubState.pads, gamepadSettings.activeSignature],
() => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey),
[hubState.pads, gamepadSettings.activeInstanceKey],
);
const activeSignature = activePad?.signature ?? null;
const activeProfile = useMemo(() => {
if (!activeSignature) {
return gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
}
return (
const storedProfile = !activeSignature
? gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT
: (
gamepadSettings?.profiles?.[activeSignature] ??
gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT
);
);
return resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
}, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]);
useEffect(() => {
@@ -132,7 +173,7 @@ export default function GamepadMappingSettings() {
saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
if (current.profiles?.[activeSignature]) return current;
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
const base = resolveGamepadProfile(current?.defaults?.profile, GAMEPAD_PROFILE_DEFAULT);
const nextProfile = createProfileForPad(activePad, base);
return {
...current,
@@ -146,6 +187,7 @@ export default function GamepadMappingSettings() {
useEffect(() => {
baselineRef.current = null;
captureCandidateRef.current = null;
}, [captureAction, activeSignature]);
useEffect(() => {
@@ -154,16 +196,45 @@ export default function GamepadMappingSettings() {
baselineRef.current = snapshotBaseline(activePad);
return;
}
const descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
let descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
if (captureAction.kind === 'button' && descriptor?.kind !== 'chord') {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (descriptor && !captureCandidateRef.current) {
/* Give the user a short window to add a modifier after the first button. Immediate capture
makes chords physically impossible because browser frames never report both presses at
precisely the same instant. */
captureCandidateRef.current = { descriptor, startedAt: now };
return;
}
if (!captureCandidateRef.current) return;
if (now - captureCandidateRef.current.startedAt < 220) return;
/* A quick tap may already be released when the chord window expires. Preserve the original
candidate so capture completes normally instead of waiting for an unrelated later press. */
descriptor = captureCandidateRef.current.descriptor;
}
if (!descriptor) return;
saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const baseProfile =
current.profiles?.[activeSignature] ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
const baseProfile = resolveGamepadProfile(
current.profiles?.[activeSignature] ?? current?.defaults?.profile,
GAMEPAD_PROFILE_DEFAULT,
);
const descriptorKey = JSON.stringify(descriptor);
const bindingsWithoutConflict = Object.fromEntries(
Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => {
if (actionId === captureAction.id) return [actionId, binding];
const sources = (binding?.sources ?? []).filter(
(source) => JSON.stringify(source) !== descriptorKey,
);
return [actionId, { ...binding, sources }];
}),
);
const nextProfile = {
...baseProfile,
bindings: {
...(baseProfile.bindings ?? {}),
/* A physical input has one owner by default. Removing an exact duplicate avoids two
toggles firing from one press while still allowing deliberate multi-button chords. */
...bindingsWithoutConflict,
[captureAction.id]: {
...(baseProfile.bindings?.[captureAction.id] ?? {}),
kind: captureAction.kind,
@@ -179,14 +250,20 @@ export default function GamepadMappingSettings() {
},
};
});
setCaptureAction(null);
/* Hub snapshots drive this effect, but capture state is React-owned UI state. Deferring its
reset to a microtask avoids a synchronous state cascade inside the effect while the id
guard prevents an older completion from cancelling a newer capture request. */
const completedActionId = captureAction.id;
queueMicrotask(() => {
setCaptureAction((current) => current?.id === completedActionId ? null : current);
});
}, [activePad, activeSignature, captureAction, saveGamepadSettings]);
const setActiveSignature = useCallback(
(signature) => {
const setActiveInstanceKey = useCallback(
(instanceKey) => {
saveGamepadSettings((prev) => ({
...(prev ?? GAMEPAD_SETTINGS_DEFAULTS),
activeSignature: signature || null,
activeInstanceKey: instanceKey || null,
}));
},
[saveGamepadSettings],
@@ -196,10 +273,10 @@ export default function GamepadMappingSettings() {
(patch) => {
saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const baseProfile =
const storedProfile =
(activeSignature && current.profiles?.[activeSignature]) ??
current?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT;
current?.defaults?.profile;
const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
const nextProfile = {
...baseProfile,
calibration: {
@@ -228,14 +305,48 @@ export default function GamepadMappingSettings() {
[activeSignature, saveGamepadSettings],
);
const updateProfile = useCallback(
(patch) => {
saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const storedProfile =
(activeSignature && current.profiles?.[activeSignature]) ??
current?.defaults?.profile;
const nextProfile = {
...resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT),
...patch,
};
if (!activeSignature) {
return {
...current,
defaults: { ...(current.defaults ?? {}), profile: nextProfile },
};
}
return {
...current,
profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile },
};
});
},
[activeSignature, saveGamepadSettings],
);
const resetActiveProfile = useCallback(() => {
/* Resetting only the selected hardware avoids erasing carefully tuned profiles for other
controllers. Device metadata is rebuilt so the profile remains recognizable offline. */
const nextProfile = createProfileForPad(activePad, GAMEPAD_PROFILE_DEFAULT);
updateProfile(nextProfile);
setCaptureAction(null);
}, [activePad, updateProfile]);
const updateBinding = useCallback(
(actionId, updater) => {
saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const baseProfile =
const storedProfile =
(activeSignature && current.profiles?.[activeSignature]) ??
current?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT;
current?.defaults?.profile;
const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
const nextBinding = updater(baseProfile.bindings?.[actionId] ?? {});
const nextProfile = {
...baseProfile,
@@ -300,39 +411,69 @@ export default function GamepadMappingSettings() {
const outputs = computeGamepadOutputs(activePad, activeProfile);
return { outputs };
}, [activePad, activeProfile]);
const controllerDescription = useMemo(() => describeController(activePad), [activePad]);
const liveValueForAction = useCallback((actionId) => {
const outputs = diagnostics?.outputs;
if (!outputs) return false;
if (actionId === 'drive') return Math.hypot(outputs.driveVector.x, outputs.driveVector.y) > 0.01;
if (actionId === 'cameraTilt') return Math.abs(outputs.cameraAxis) > 0.01;
if (actionId === 'mainBrush') return Math.abs(outputs.auxAxis.main) > 0.01;
if (actionId === 'sideBrush') return Math.abs(outputs.auxAxis.side) > 0.01;
return Boolean(outputs.buttons[actionId]);
}, [diagnostics]);
return (
<CardFrame
title="Controller"
meta={activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'}
actions={
<button type="button" disabled={!activePad} onClick={resetActiveProfile} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
Reset profile
</button>
}
bodyClassName="space-y-2 p-1 text-sm"
>
{captureAction && (
<p className="mx-auto w-full max-w-lg rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
Capturing {captureAction.label}...
</p>
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
<span>Release controls, then move or press the input for {captureAction.label}.</span>
<button type="button" onClick={() => setCaptureAction(null)} className="button-dark px-1 py-0.5 text-xs">
Cancel
</button>
</div>
)}
<div className="space-y-1">
<SettingsGroupLabel>Connected controller</SettingsGroupLabel>
{hubState.pads.length === 0 ? (
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
{hubState.error ? (
<p className="mx-auto w-full max-w-lg rounded border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
Controller access failed: {hubState.error}
</p>
) : hubState.pads.length === 0 ? (
<p className="mx-auto w-full max-w-lg text-sm text-white">
{hubState.supported === false
? 'This browser does not support controllers.'
: 'No controller detected. Connect it, focus this page, then press a button.'}
</p>
) : (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<select
value={activeSignature ?? ''}
onChange={(event) => setActiveSignature(event.target.value)}
value={activePad?.instanceKey ?? ''}
onChange={(event) => setActiveInstanceKey(event.target.value)}
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
{hubState.pads.map((pad) => (
<option key={pad.signature} value={pad.signature}>
{pad.id || 'Unknown controller'}
<option key={pad.instanceKey} value={pad.instanceKey}>
{pad.id || 'Unknown controller'} (slot {pad.index + 1})
</option>
))}
</select>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
{activePad?.mapping ?? 'unknown'}
</span>
<p className="col-span-full truncate text-xs text-slate-300" title={activePad?.id}>
{controllerDescription.description ?? activePad?.id}
</p>
</div>
)}
</div>
@@ -342,6 +483,23 @@ export default function GamepadMappingSettings() {
{/* Calibration controls stay in one stacked column because range inputs become harder to
tune when squeezed into multiple narrow columns. */}
<div className="grid gap-1">
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">Button prompts</span>
<select
value={activeProfile.promptStyle ?? 'auto'}
onChange={(event) => updateProfile({ promptStyle: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="auto">Automatic</option>
<option value="xbox">Xbox</option>
<option value="playstation">PlayStation</option>
<option value="switch">Nintendo</option>
<option value="standard">Generic</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
</label>
<SliderField
label="Drive deadzone"
description="Ignore small drive stick drift"
@@ -351,6 +509,29 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.driveDeadzone ?? 0.18}
onChange={(value) => updateCalibration({ driveDeadzone: value })}
/>
<CurveField
label="Drive response"
value={activeProfile.calibration?.driveCurve ?? 'linear'}
onChange={(value) => updateCalibration({ driveCurve: value })}
/>
<SliderField
label="Full-stick speed"
description="Maximum wheel output at full stick"
min={50}
max={500}
step={10}
value={activeProfile.calibration?.baseSpeed ?? 500}
onChange={(value) => updateCalibration({ baseSpeed: value })}
/>
<SliderField
label="Turbo drive speed"
description="Maximum output while holding the turbo modifier"
min={50}
max={500}
step={10}
value={activeProfile.calibration?.turboSpeed ?? 500}
onChange={(value) => updateCalibration({ turboSpeed: value })}
/>
<SliderField
label="Camera deadzone"
description="Ignore small camera tilt drift"
@@ -360,6 +541,11 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
/>
<CurveField
label="Camera response"
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
onChange={(value) => updateCalibration({ cameraCurve: value })}
/>
<SliderField
label="Aux deadzone"
description="Ignore small trigger noise"
@@ -369,6 +555,11 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.auxDeadzone ?? 0.05}
onChange={(value) => updateCalibration({ auxDeadzone: value })}
/>
<CurveField
label="Brush response"
value={activeProfile.calibration?.auxCurve ?? 'linear'}
onChange={(value) => updateCalibration({ auxCurve: value })}
/>
<SliderField
label="Side brush scale"
description="Scale side brush output"
@@ -378,13 +569,22 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.auxSideScale ?? 0.55}
onChange={(value) => updateCalibration({ auxSideScale: value })}
/>
<SliderField
label="Precision speed"
description="Maximum drive speed while holding the precision modifier"
min={20}
max={250}
step={5}
value={activeProfile.calibration?.precisionSpeed ?? 100}
onChange={(value) => updateCalibration({ precisionSpeed: value })}
/>
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
{/* Camera mode is styled like the sliders so calibration controls read as one group
even though this specific setting is a select instead of a range input. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Camera mode</span>
<select
value={activeProfile.calibration?.cameraMode ?? 'absolute'}
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
@@ -407,6 +607,16 @@ export default function GamepadMappingSettings() {
</div>
<div className="space-y-2">
<label className="mx-auto block w-full max-w-lg">
<span className="sr-only">Filter controller actions</span>
<input
type="search"
value={actionFilter}
onChange={(event) => setActionFilter(event.target.value)}
placeholder="Find a controller action"
className="field-input w-full px-1.5 py-1 text-sm"
/>
</label>
{Object.entries(grouped).map(([section, actions]) => (
<div key={section} className="space-y-1">
<SettingsGroupLabel>{section}</SettingsGroupLabel>
@@ -421,10 +631,13 @@ export default function GamepadMappingSettings() {
key={action.id}
action={action}
source={source}
sourceLabel={formatControllerBinding(activeProfile, action.id, activePad)}
liveValue={liveValueForAction(action.id)}
isCapturing={captureAction?.id === action.id}
onClear={handleClear}
onCapture={setCaptureAction}
onInvert={handleInvert}
disabled={!activePad}
/>
);
})}
@@ -434,11 +647,12 @@ export default function GamepadMappingSettings() {
</div>
<div className="space-y-1">
<SettingsGroupLabel>Diagnostics</SettingsGroupLabel>
{!activePad ? (
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
null
) : (
<div className="mx-auto w-full max-w-lg space-y-1 rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
<details className="mx-auto w-full max-w-lg rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
<summary className="cursor-pointer text-sm font-semibold text-white">Advanced diagnostics</summary>
<div className="mt-1 space-y-1">
<p className="text-white">Raw axes</p>
<div className="grid grid-cols-2 gap-1">
{activePad.axes.map((value, index) => (
@@ -475,7 +689,8 @@ export default function GamepadMappingSettings() {
</div>
</>
)}
</div>
</div>
</details>
)}
</div>
</CardFrame>
@@ -40,6 +40,25 @@ export const ACTIONS = [
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
{ id: 'boostModifier', label: 'Turbo modifier', kind: 'button', section: 'Driving' },
{ id: 'slowModifier', label: 'Precision modifier', kind: 'button', section: 'Driving' },
{ id: 'hornHonk', label: 'Horn (hold)', kind: 'button', section: 'Audio and chat' },
{ id: 'micPtt', label: 'Microphone push to talk', kind: 'button', section: 'Audio and chat' },
{ id: 'chatFocus', label: 'Focus chat', kind: 'button', section: 'Audio and chat' },
{ id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' },
{ id: 'songNoteUp', label: 'Song note up', kind: 'button', section: 'Audio and chat' },
{ id: 'songNoteDown', label: 'Song note down', kind: 'button', section: 'Audio and chat' },
{ id: 'homeAssistantOn', label: 'Next room control on', kind: 'button', section: 'Room controls' },
{ id: 'homeAssistantOff', label: 'Next room control off', kind: 'button', section: 'Room controls' },
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
{ id: 'auxMainReverse', label: 'Main brush reverse', kind: 'button', section: 'Aux buttons' },
{ id: 'auxSideForward', label: 'Side brush forward', kind: 'button', section: 'Aux buttons' },
{ id: 'auxSideReverse', label: 'Side brush reverse', kind: 'button', section: 'Aux buttons' },
{ id: 'auxVacuumFast', label: 'Vacuum max', kind: 'button', section: 'Aux buttons' },
{ id: 'auxVacuumSlow', label: 'Vacuum low', kind: 'button', section: 'Aux buttons' },
{ id: 'auxAllForward', label: 'All motors forward', kind: 'button', section: 'Aux buttons' },
];
export const CAPTURE_AXIS_THRESHOLD = 0.45;
@@ -33,10 +33,10 @@ export function groupActions(actions) {
}, {});
}
export function pickActivePad(pads, activeSignature) {
export 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];
@@ -62,10 +62,13 @@ function detectAxisCapture(pad, baseline, action) {
if (action.kind === 'axisPair') {
const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2);
if (top.length < 2) return null;
const orderedIndices = top.map((entry) => entry.index).sort((a, b) => a - b);
return {
kind: 'axisPair',
x: top[0].index,
y: top[1].index,
// Browsers expose two-dimensional controls as adjacent X/Y axes. Sorting the captured pair
// prevents whichever direction moved first from randomly swapping steering and throttle.
x: orderedIndices[0],
y: orderedIndices[1],
...(action.invertDefaults ?? {}),
};
}
@@ -80,16 +83,25 @@ function detectAxisCapture(pad, baseline, action) {
function detectButtonCapture(pad, baseline, action) {
const buttons = pad.buttons ?? [];
const newlyPressed = [];
for (let i = 0; i < buttons.length; i += 1) {
const btn = buttons[i];
const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0;
if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) {
const baselineValue = baseline.buttons?.[i]?.value ?? 0;
const baselinePressed = baseline.buttons?.[i]?.pressed ?? false;
if (!baselinePressed && (btn?.pressed || value - baselineValue > CAPTURE_BUTTON_THRESHOLD)) {
if (action.kind === 'axis') {
return { kind: 'buttonAxis', index: i };
}
return { kind: 'button', index: i };
newlyPressed.push({ kind: 'button', index: i });
}
}
if (newlyPressed.length > 1) {
// Capturing all buttons observed in the same frame makes intentional modifier chords possible
// without a separate advanced editor, while a normal single press keeps the compact shape.
return { kind: 'chord', inputs: newlyPressed };
}
if (newlyPressed.length === 1) return newlyPressed[0];
const axes = pad.axes ?? [];
for (let i = 0; i < axes.length; i += 1) {
const value = axes[i] ?? 0;
@@ -0,0 +1,49 @@
// Controller Capture Tests
// Purpose: Verifies that binding capture cannot select held controls or swap stick axes randomly.
// Scope: Covers the pure capture detector used by the Controller settings surface.
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildDescriptorFromCapture, snapshotBaseline } from './helpers.js';
function button(pressed = false, value = pressed ? 1 : 0) {
return { pressed, value };
}
test('a button held before capture is ignored', () => {
const baselinePad = { axes: [0, 0], buttons: [button(true), button(false)] };
const currentPad = { axes: [0, 0], buttons: [button(true), button(false)] };
const descriptor = buildDescriptorFromCapture(
currentPad,
snapshotBaseline(baselinePad),
{ kind: 'button' },
);
assert.equal(descriptor, null);
});
test('axis-pair capture assigns the lower adjacent axis to X regardless of movement order', () => {
const baseline = snapshotBaseline({ axes: [0, 0, 0, 0], buttons: [] });
const descriptor = buildDescriptorFromCapture(
{ axes: [0, 0, -0.7, 0.9], buttons: [] },
baseline,
{ kind: 'axisPair', invertDefaults: { invertY: true } },
);
assert.deepEqual(descriptor, {
kind: 'axisPair',
x: 2,
y: 3,
invertY: true,
});
});
test('simultaneous new buttons are represented as a chord', () => {
const baseline = snapshotBaseline({ axes: [], buttons: [button(), button(), button()] });
const descriptor = buildDescriptorFromCapture(
{ axes: [], buttons: [button(true), button(), button(true)] },
baseline,
{ kind: 'button' },
);
assert.deepEqual(descriptor, {
kind: 'chord',
inputs: [{ kind: 'button', index: 0 }, { kind: 'button', index: 2 }],
});
});
+12 -6
View File
@@ -2,14 +2,14 @@
// Purpose: Defines the Help Content View module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
import { getHelpContent } from '../../help/content.js';
function KeyPill({ actionId, keymap }) {
const value = keymap?.[actionId]?.[0] ?? '';
function KeyPill({ actionId }) {
return (
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
{formatKeyLabel(value)}
<ControlHint actionId={actionId} />
</span>
);
}
@@ -130,14 +130,20 @@ function KeyboardGroup({ group, keymap }) {
}
function KeyboardBlock({ block, keymap }) {
const runtime = useControllerRuntime();
if (!block) return null;
const usingController = runtime.inputMethod === 'controller';
return (
<div className="space-y-0.5">
{/* Heading and footnote share a row when possible and wrap independently
when the Help card is mounted in a narrow desktop column. */}
<div className="flex flex-wrap items-center justify-between gap-0.5 text-xs text-slate-200">
<span className="font-semibold">{block.title}</span>
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
<span className="font-semibold">{usingController ? 'Controller controls' : block.title}</span>
<span className="text-[0.7rem] text-slate-400">
{usingController
? 'Per-controller; adjust bindings in Settings → Controller.'
: block.footnote}
</span>
</div>
{/* Two keyboard groups fit comfortably once the Help surface reaches 32rem.
Using the real content threshold restores the established old-page layout
@@ -3,8 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
@@ -184,7 +183,6 @@ export default function HomeAssistantControls() {
}
function HomeAssistantControlsContent() {
const keymap = useControlSelector((control) => control.state.keymap);
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions();
@@ -196,8 +194,8 @@ function HomeAssistantControlsContent() {
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
const onKeyLabel = <ControlHint actionId="homeAssistantOn" />;
const offKeyLabel = <ControlHint actionId="homeAssistantOff" />;
if (!ha?.enabled) {
return (
@@ -3,7 +3,7 @@
import { useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import ControlHint from '../../../ControlHint/index.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
@@ -45,7 +45,6 @@ export default function BottomLeftPod({ roverId }) {
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const canControl = useCanControlRover(roverId);
const hornPointerRef = useRef(null);
@@ -80,13 +79,13 @@ export default function BottomLeftPod({ roverId }) {
{/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div>
) : (
@@ -3,7 +3,7 @@
import { useCallback } from 'react';
import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import ControlHint from '../../../ControlHint/index.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
@@ -32,7 +32,6 @@ export default function BottomRightPod({ roverId }) {
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setServoAngle } = useControlActions();
const canControl = useCanControlRover(roverId);
const config = camera?.config;
@@ -86,8 +85,8 @@ export default function BottomRightPod({ roverId }) {
</button>
{/* These positions continue around the same circle just beyond the two slider endpoints.
Together they occupy the open third facing the corner without enlarging the pod. */}
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraDown" />} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraUp" />} /></div>
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
</div>
) : showCameraControls && enabled ? (
@@ -4,14 +4,12 @@ import { useCallback, useState } from 'react';
import { FaComment } from 'react-icons/fa';
import { useChatActions } from '../../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import ControlHint from '../../../ControlHint/index.jsx';
import HudChatInput from '../../HudChatInput/index.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function ChatExpansion({ podOpen }) {
const role = useSessionSelector((state) => state.session?.role || null);
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
const { blurChat, focusChat } = useChatActions();
const [open, setOpen] = useState(false);
@@ -50,7 +48,7 @@ export default function ChatExpansion({ podOpen }) {
<FaComment aria-hidden="true" />
{/* The pill reflects the live keymap so remapping chat focus updates this
compact HUD hint without duplicating or hardcoding the default key. */}
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
<KeyPill label={<ControlHint actionId="chatFocus" />} />
</button>
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
@@ -4,8 +4,8 @@
// the archived desktop layout retains its previous DriveDockAction behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaChargingStation } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import { useControlActions } from '../../../../controls/index.js';
import ControlHint from '../../../ControlHint/index.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
@@ -252,7 +252,6 @@ function UndockTransitionGhost({ onFinish }) {
export default function DockingHud({ roverId }) {
const layout = useDriverLayout();
const actions = useControlActions();
const keymap = useControlSelector((control) => control.state.keymap);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
@@ -282,12 +281,8 @@ export default function DockingHud({ roverId }) {
/* Mobile already presents its own touch-oriented driving controls. The docked
action therefore keeps its plain-language instruction without advertising a
keyboard shortcut that is irrelevant on that layout. */
const driveKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.driveMacro?.[0])
: '';
const dockKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.dockMacro?.[0])
: '';
const driveKeyLabel = layout === 'desktop' ? <ControlHint actionId="driveMacro" /> : '';
const dockKeyLabel = layout === 'desktop' ? <ControlHint actionId="dockMacro" /> : '';
const batteryPodOpen = podSettings?.battery !== false;
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
+3 -8
View File
@@ -16,8 +16,8 @@ import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useControlActions } from '../../controls/index.js';
import ControlHint from '../ControlHint/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
@@ -283,12 +283,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
);
}
function keyLabelFor(keymap, actionId) {
return formatKeyLabel(keymap?.[actionId]?.[0]);
}
function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap);
const rows = [
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
@@ -305,7 +300,7 @@ function PtzControlReference() {
{rows.map(([label, actionId]) => (
<div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span>
<KeyPill label={keyLabelFor(keymap, actionId)} />
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
))}
</CardFrame>
@@ -1,34 +1,32 @@
import { useMemo } from 'react';
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButton from '../SocialButton/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { getSocialById } from '../../lib/socials.js';
function ControlRow({ label, keyLabel }) {
function ControlRow({ label, actionId }) {
return (
<div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200">
<span>{label}</span>
<KeyPill label={keyLabel} />
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
);
}
function DesktopQuickstart({ keymap }) {
function DesktopQuickstart() {
return (
<div className="space-y-0.5">
<p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p>
<div className="space-y-0.5">
<p className="text-sm text-slate-200">2. Drive with these keybindings:</p>
<div className="space-y-0.5">
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
<ControlRow label="Turn Left" keyLabel={formatKeyLabel(keymap?.driveLeft?.[0])} />
<ControlRow label="Turn Right" keyLabel={formatKeyLabel(keymap?.driveRight?.[0])} />
<ControlRow label="Move faster" keyLabel={formatKeyLabel(keymap?.boostModifier?.[0])} />
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
<ControlRow label="Forward" actionId="driveForward" />
<ControlRow label="Backward" actionId="driveBackward" />
<ControlRow label="Turn Left" actionId="driveLeft" />
<ControlRow label="Turn Right" actionId="driveRight" />
<ControlRow label="Move faster" actionId="boostModifier" />
<ControlRow label="Move slower" actionId="slowModifier" />
</div>
</div>
<p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p>
@@ -75,9 +73,7 @@ export default function QuickstartOverlay({
onToggleShowOnLoad,
onClose,
}) {
const rawKeymap = useControlSelector((control) => control.state.keymap);
const isDesktop = layout === 'desktop';
const keymap = useMemo(() => rawKeymap || {}, [rawKeymap]);
if (!visible) return null;
@@ -97,7 +93,7 @@ export default function QuickstartOverlay({
</div>
<div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}>
<section className="space-y-0.5 border-b border-slate-700">
{isDesktop ? <DesktopQuickstart keymap={keymap} /> : <MobileQuickstart />}
{isDesktop ? <DesktopQuickstart /> : <MobileQuickstart />}
</section>
{/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
<section className="space-y-0.5">
+2 -3
View File
@@ -18,7 +18,7 @@ import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { useSocket } from '../../context/SocketContext.jsx';
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import {
DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS,
@@ -129,7 +129,6 @@ function reconnectSocketWithTransport(socket, transport) {
}
export default function SettingsPanel() {
const keymap = useControlSelector((control) => control.state.keymap);
const roverId = useControlSelector((control) => control.state.roverId);
const { sendOiCommand, setSensorStream } = useControlActions();
const canControl = Boolean(roverId);
@@ -170,7 +169,7 @@ export default function SettingsPanel() {
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
const videoFilterCycleKeyLabel = <ControlHint actionId="videoFilterCycle" />;
useEffect(() => {
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
@@ -4,7 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fieldClass } from '../constants.js';
import { useControlSelector } from '../../../controls/index.js';
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
import ControlHint from '../../ControlHint/index.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
import { bytesToBase64, buildAuthHeader } from './base64.js';
@@ -28,7 +28,6 @@ export default function VipAudioUploadCard({
readyMicWhip,
stopMicWhip,
}) {
const keymap = useControlSelector((control) => control.state.keymap);
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
openMicEnabled: false,
@@ -81,7 +80,7 @@ export default function VipAudioUploadCard({
const whipLinkActive = !clipMode && (micState === 'live' || micState === 'starting');
const clipRecording = clipMode && clipState === 'recording';
const clipSending = clipMode && clipState === 'sending';
const pttKeyLabel = formatKeyLabel(keymap?.micPtt?.[0]) || 'M';
const pttKeyLabel = <ControlHint actionId="micPtt" />;
const setPttMode = useCallback(
(nextMode) => {
@@ -12,8 +12,8 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useControlActions } from '../../controls/index.js';
import ControlHint from '../ControlHint/index.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -307,12 +307,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
);
}
function keyLabelFor(keymap, actionId) {
return formatKeyLabel(keymap?.[actionId]?.[0]);
}
function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap);
const rows = [
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
@@ -331,7 +326,7 @@ function PtzControlReference() {
<span className="text-slate-400">{label}</span>
{/* Use the same key display component as the rest of the UI so PTZ
controls read as normal mapped controls instead of custom labels. */}
<KeyPill label={keyLabelFor(keymap, actionId)} />
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
))}
</CardFrame>
+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));
}
@@ -14,7 +14,7 @@ import { useDriveDockState } from '../../../components/DriveDockAction/driveDock
import { useControlActions, useControlSelector } from '../../../controls/index.js';
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
import RawUserPilePanel from '../../../components/RawUserPilePanel/index.jsx';
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
import ControlHint from '../../../components/ControlHint/index.jsx';
import GPIOToggleControl from '../../../components/GPIOToggleControl/index.jsx';
import HornControl from '../../../components/HornControl/index.jsx';
import CameraTiltControl from '../../../components/CameraTiltControl/index.jsx';
@@ -54,7 +54,6 @@ function TopDownMapPanel() {
function DriveDockPanel() {
const roverId = useControlSelector((control) => control.state.roverId);
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
const keymap = useControlSelector((control) => control.state.keymap);
const camera = useControlSelector((control) => control.state.camera);
const horn = useControlSelector((control) => control.state.horn);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
@@ -81,11 +80,11 @@ function DriveDockPanel() {
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
const headlightLabel = formatKeyLabel(keymap?.headlightToggle?.[0]);
const laserLabel = formatKeyLabel(keymap?.laserToggle?.[0]);
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
const headlightLabel = <ControlHint actionId="headlightToggle" />;
const laserLabel = <ControlHint actionId="laserToggle" />;
const hornLabel = <ControlHint actionId="hornHonk" />;
const upLabel = <ControlHint actionId="cameraUp" />;
const downLabel = <ControlHint actionId="cameraDown" />;
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
/*
Precision movement mode also tightens the servo slider step. The command
+62 -14
View File
@@ -11,7 +11,9 @@ export const INPUT_SETTINGS_DEFAULTS = {
};
export const GAMEPAD_PROFILE_DEFAULT = {
behaviorVersion: 2,
label: 'Default',
promptStyle: 'auto',
calibration: {
driveDeadzone: 0.18,
cameraDeadzone: 0.08,
@@ -19,9 +21,12 @@ export const GAMEPAD_PROFILE_DEFAULT = {
driveCurve: 'linear',
cameraCurve: 'linear',
auxCurve: 'linear',
cameraMode: 'absolute',
cameraMode: 'velocity',
cameraSensitivity: 60,
auxSideScale: 0.55,
baseSpeed: 500,
turboSpeed: 500,
precisionSpeed: 100,
},
bindings: {
drive: {
@@ -30,24 +35,15 @@ export const GAMEPAD_PROFILE_DEFAULT = {
},
cameraTilt: {
kind: 'axis',
sources: [
{ kind: 'axis', index: 3, invert: true },
{ kind: 'axis', index: 1, invert: true },
],
sources: [{ kind: 'axis', index: 3, invert: true }],
},
mainBrush: {
kind: 'axis',
sources: [
{ kind: 'buttonAxis', index: 6 },
{ kind: 'axis', index: 2, invert: false },
],
sources: [{ kind: 'buttonAxis', index: 6 }],
},
sideBrush: {
kind: 'axis',
sources: [
{ kind: 'buttonAxis', index: 7 },
{ kind: 'axis', index: 5, invert: false },
],
sources: [{ kind: 'buttonAxis', index: 7 }],
},
vacuum: {
kind: 'button',
@@ -81,11 +77,63 @@ export const GAMEPAD_PROFILE_DEFAULT = {
kind: 'button',
sources: [],
},
boostModifier: {
kind: 'button',
sources: [{ kind: 'button', index: 10 }],
},
slowModifier: {
kind: 'button',
sources: [{ kind: 'button', index: 11 }],
},
hornHonk: {
kind: 'button',
sources: [{ kind: 'button', index: 12 }],
},
micPtt: {
kind: 'button',
sources: [{ kind: 'button', index: 13 }],
},
videoFilterCycle: {
kind: 'button',
sources: [{ kind: 'button', index: 14 }],
},
chatFocus: {
kind: 'button',
sources: [{ kind: 'button', index: 15 }],
},
songNoteUp: {
kind: 'button',
sources: [],
},
songNoteDown: {
kind: 'button',
sources: [],
},
homeAssistantOn: {
kind: 'button',
sources: [],
},
homeAssistantOff: {
kind: 'button',
sources: [],
},
/* These direct digital aux actions mirror the keyboard contract exactly. They start empty
because the analog trigger/stick defaults above are friendlier on a controller, but users
can bind either style without the shared control system knowing which device produced it. */
auxMainForward: { kind: 'button', sources: [] },
auxMainReverse: { kind: 'button', sources: [] },
auxSideForward: { kind: 'button', sources: [] },
auxSideReverse: { kind: 'button', sources: [] },
auxVacuumFast: { kind: 'button', sources: [] },
auxVacuumSlow: { kind: 'button', sources: [] },
auxAllForward: { kind: 'button', sources: [] },
},
};
export const GAMEPAD_SETTINGS_DEFAULTS = {
activeSignature: null,
// Runtime instance selection includes the browser slot so two identical controllers remain
// distinguishable, while profiles below stay keyed by reusable hardware signature.
activeInstanceKey: null,
profiles: {},
defaults: {
profile: GAMEPAD_PROFILE_DEFAULT,