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