Compare commits

...
7 Commits
Author SHA1 Message Date
legop3 17b1404157 converge everything into one data folder 2026-09-13 22:10:15 -04:00
legop3 b199f45eb2 add bars to thingy flingy 2026-09-13 15:43:41 -04:00
legop3 0f08fb3f0d Merge pull request #25 from legop3/newcontroller
Newcontroller
2026-09-13 02:09:38 -04:00
legop3 0f5a33c1de slop tank steering 2026-09-13 01:41:32 -04:00
legop3 8e96c3cdae glorp! 2026-09-12 20:52:34 -04:00
legop3 ba5c1c5d25 dont stop assignments for help rovers, just allow people to leave them. 2026-09-12 18:58:32 -04:00
legop3 6a914faffb Merge pull request #24 from legop3/HELP
Help
2026-09-12 18:34:35 -04:00
57 changed files with 3077 additions and 519 deletions
+640
View File
@@ -0,0 +1,640 @@
# Server administration and container migration
## Status
This document records the agreed design and implementation order. None of the work described here is implemented merely by this document.
The work is deliberately split into two phases:
1. Finish the server-side configuration, administration, persistence, backup, restore, and media-routing changes while the server still uses its current systemd deployment.
2. Containerize the already-finished application, publish images through GHCR, and add container-aware update and restart controls.
Phase 1 must be complete and verified before Phase 2 begins. Containerization must not become a second configuration migration or a reason to maintain two persistence layouts.
## Decision log
- 2026-09-13: Use an internal Node `/video` proxy. The public reverse proxy will send every site path to Node, Node will strip `/video` and stream WHEP signaling to MediaMTX on loopback, and MediaMTX port 8889 will not be exposed publicly. MediaMTX cannot independently add a WHEP base-path prefix; making `video` part of every stream name would still leave two HTTP servers competing for the public HTTPS listener.
- 2026-09-13: Preserve the existing flat `server/data` layout instead of moving established stores into decorative `state`, `cache`, or `generated` parents. Packaged application assets remain with the application.
- 2026-09-13: "The server" in the filesystem rule specifically means the main Node.js application. Every file it intentionally creates or modifies, including disposable scratch work, must be beneath `SERVER_DATA_DIR`. Installers, systemd, Docker, BlueZ, and unavoidable internal behavior of external libraries are outside that application boundary.
## Final goals
- `config.yaml` and `config.example.yaml` no longer exist.
- All operator-controlled server configuration is stored in a validated database and managed through the web UI.
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
- A complete backup can capture that one data directory consistently, and a restore can safely replace it.
- A one-time legacy importer moves an existing `config.yaml` installation into the new configuration database.
- A dedicated `/admin` application contains all server administration.
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
- Release images are built automatically and published to GHCR.
- The admin UI can restart, update, health-check, and roll back the application container without giving the main application direct Docker access.
- The final host installation contains as little project-specific material as possible: a Compose file, a data directory, and unavoidable hardware preparation.
## Important boundary: application files versus server data
The data-directory rule applies to everything mutable or instance-specific that the server reads, writes, generates, or persists at runtime. It does not mean copying the application itself into the data directory.
Packaged, read-only application material remains with the application and later inside the image:
- Server source and production dependencies
- Built web UI assets
- Static sound and image assets shipped by the repository
- MediaMTX, ffmpeg, ffprobe, neolink, and TTS tools
- Kinect and Balance Board workers
- Helper scripts shipped as part of the application
The single data directory owns:
- Server configuration and secrets
- Administrator accounts
- Identity and permission records
- Fleet reports
- Persistent service state
- Audit history
- Generated MediaMTX configuration
- Snapshots and replay segments
- Finished replays
- PTZ-generated audio
- Barcode/TTS caches
- Disposable audio-forward and replay-build work under `runtime/`
- Backup and restore coordination state
- Any future file deliberately created or modified by the Node application
Application-owned scratch work must use `SERVER_DATA_DIR/runtime`, even when it is safe to lose on restart. Tests may use the operating system temporary directory because they are not the running server application. Packaged programs and host services can manage their own internal temporary state, but any output path explicitly selected by Node must follow the single-root rule.
# Phase 1: complete the application before containerization
Phase 1 is server, web UI, installer, and migration work only. The current systemd deployment remains the runtime while these contracts are changed and verified.
## 1. Establish the single data-directory contract
The normal development and legacy-install location remains `server/data`. The path continues to be overridable through `SERVER_DATA_DIR`, which will later be set to `/data` in the container.
A representative final layout is:
```text
server/data/
├── configuration.sqlite
├── identity.sqlite
├── fleet-reports.sqlite
├── mediamtx.yml
├── existing service JSON stores
├── barcode-tts-cache/
├── rover-snapshots/
├── replay-segments/
├── replays/
├── ptz-camera-audio/
├── runtime/
│ ├── audio-forward/
│ ├── replay-builds/
│ └── room-camera-replay-builds/
└── system/
├── backup-staging/
└── restore/
```
The exact number of databases is not important. A centralized admin UI does not require unrelated services to share one SQLite connection. Keeping identity and high-volume fleet reporting in their existing databases may remain simpler, provided every database is under the same data directory.
Required work:
- Audit every server filesystem read and write.
- Make every persistent path resolve from the shared data-path helper.
- Move rover and PTZ snapshots out of `/var/lib/rover-snapshots` and into the data directory.
- Remove separate persistent replay path configuration and keep replay segments and completed replays under the data directory.
- Keep generated MediaMTX configuration at its established `data/mediamtx.yml` path.
- Keep barcode speech, PTZ speech, and similar caches under the data directory.
- Check native workers and child-process scripts for hidden working-directory assumptions.
- Update health reporting to inspect the new paths.
- Update the legacy installer so the service receives one `SERVER_DATA_DIR` rather than several unrelated persistent paths.
- Add a focused test that runs services against a temporary data directory and proves that no test artifact escapes it.
- Document which files are durable and which cache directories may be discarded.
The audit must search direct filesystem calls as well as environment-variable defaults. Existing calls that default to `/var/lib`, the repository directory, or an implicit current working directory must be corrected.
## 2. Replace YAML with a configuration database
Create a synchronous configuration service backed by `better-sqlite3`. Synchronous reads preserve the server's current startup model, in which many services load their configuration while modules are required.
The configuration database should own at least:
- The current complete configuration document
- A monotonically increasing configuration revision
- Previous configuration revisions
- The administrator account catalog and password hashes
- Persistent administrative audit events
- Database/schema migration state
The configuration schema must explicitly describe every supported field. A validation library should be used rather than assembling an ad hoc validator by hand.
Current configuration areas to migrate include:
- Server timezone and public instance identity
- Administrator accounts and Discord identities
- Inter-instance directories and profile
- LLM commentary and Overseer Control
- Barcode games
- Media and WebRTC ICE candidates
- Bandwidth-saving policy
- Audio forwarding and global audio levels
- Home Assistant, Neato, lift, entities, and button mappings
- Room cameras and PTZ camera
- Kinect and Balance Board
- Button box and barcode scanner
- Command names
- Discord bot, channels, and roles
- Social links and driver content
- Fleet-report collection, retention, privacy, and delivery
Required behavior:
- A missing value receives a documented safe default.
- Optional integrations default to disabled.
- Unknown fields are rejected rather than silently ignored.
- Invalid configuration never becomes the active revision.
- A complete revision is written atomically.
- Updates include the acting administrator and timestamp.
- Concurrent editors use revision checking so an older browser cannot overwrite a newer change silently.
- Secrets are never included in ordinary configuration responses, logs, diffs, or audit metadata.
- Secret inputs support replace and clear operations without returning the current value to the browser.
- At least one lockdown administrator must always remain.
- An administrator cannot accidentally remove the only account capable of repairing administration.
Configuration changes use one intentionally simple application rule:
1. Validate the complete proposed document.
2. Commit it as a new database revision.
3. Report that an application restart is required.
4. Let the administrator restart immediately or later.
5. Load one coherent configuration snapshot at the next process start.
Operational actions such as changing server mode, locking a rover, or issuing a rover command remain live actions and do not become restart-required configuration edits.
After migration is complete:
- Remove the YAML configuration loader.
- Remove `SERVER_CONFIG`.
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
## 3. Build the one-time legacy configuration importer
Existing installations need an explicit, bounded migration from their old `config.yaml`. This importer is not a compatibility loader and must never become a permanent second source of truth.
The importer must:
- Accept an explicitly selected legacy YAML file.
- Parse the complete legacy document.
- Map every recognized field into the new configuration schema.
- Preserve existing bcrypt administrator password hashes.
- Preserve lockdown roles and Discord IDs.
- Preserve secrets without printing them.
- Apply new defaults for fields absent from an older configuration.
- Detect unknown fields and show them in the migration report.
- Validate the entire result before writing anything.
- Refuse to overwrite an already-configured database unless an explicit replacement workflow is used.
- Support a dry-run that reports changes without writing.
- Write the imported configuration and migration metadata atomically.
- Record the source format and migration time without storing secret values in the audit event.
- Verify that the resulting configuration can be read back before considering the import successful.
The first-run UI should recognize that a legacy configuration is available and offer the import after the operator proves possession of the one-time setup code. A command-line import path should also exist for recovery and unattended migration.
After a successful import, the server must use only the database. The legacy YAML file should not be watched, re-read, or used as fallback. Removal of the old file should be an explicit final migration step after the operator has downloaded a backup or otherwise confirmed the import.
## 4. Add first-run setup
The server must boot safely with an empty data directory and without any YAML file.
Required flow:
1. Initialize the databases and safe default configuration.
2. Keep all optional external integrations disabled.
3. Generate a one-time setup code and print it to the server log.
4. Serve a restricted `/setup` application.
5. Require the setup code before creating the first lockdown administrator.
6. Offer legacy configuration import when a legacy source was explicitly provided.
7. Otherwise collect only the minimum information needed to establish the instance.
8. Permanently disable setup after the first lockdown administrator exists.
A recovery command must be available for resetting or creating a lockdown administrator from the server console. Environment variables must not act as a recurring authentication bypass on every boot.
## 5. Build the centralized admin application
Create a dedicated `/admin` route instead of continuing to expand the existing driver-page admin panel.
The application should organize existing and new controls into:
- Overview and service health
- Fleet and rover operations
- Users, administrators, verification, and permissions
- Media and bandwidth
- Discord and Home Assistant
- PTZ and room cameras
- Kinect and Balance Board
- Button box, barcode scanner, barcode games, and lift
- LLM commentary and Overseer Control
- Social links and driver content
- Fleet reports
- Application and administrative logs
- Persistent audit history
- Configuration revisions
- Backup and restore
- System restart, and later container update
Existing components and server operations should be moved or reused rather than duplicated. The identity database page and other isolated administrative pages should become sections of this centralized application where doing so preserves their existing behavior.
Authorization rules:
- Normal administrators may perform routine fleet operations.
- Lockdown administrators manage accounts, secrets, server configuration, backup restoration, and other destructive operations.
- Sensitive changes require recent password confirmation.
- Server-side authorization remains authoritative for every operation; hiding a control in React is not an access check.
Configuration forms should be explicit, typed forms. There should be no raw YAML editor and no generic JSON editor for ordinary configuration. Repeatable definitions such as cameras, entities, links, and buttons need simple add, remove, reorder, and test workflows.
## 6. Implement complete backup and restore
Everything durable living under one data directory makes the backup boundary simple, but copying live SQLite files and JSON files without coordination would not guarantee a consistent backup. The implementation must create a consistent snapshot before archiving it.
### Full backup
The primary admin action is **Download full backup**. A full backup includes the entire durable data payload:
- Configuration and secrets
- Administrator accounts
- Identity and permissions
- Fleet history
- Persistent service state
- Snapshots and replay media
- Generated and cached files that are part of the current server state
- A manifest describing the application and schema versions
The backup service must:
1. Require a lockdown administrator and recent password confirmation.
2. Enter a short maintenance/snapshot state that prevents new persistent mutations.
3. Ask services with buffered state to flush it, stop active audio/replay workers, and clear `runtime/` so FIFOs and incomplete scratch files are never archived.
4. Create consistent SQLite snapshots using SQLite's supported backup/checkpoint facilities rather than copying active WAL files blindly.
5. Copy non-database durable files into temporary staging.
6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums.
7. Create the archive in temporary storage and stream it to the browser.
8. Remove temporary staging whether the operation succeeds or fails.
9. Resume normal mutations after the consistent snapshot has been captured; archive compression does not need to hold the server in maintenance mode.
The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory.
`runtime/` is inside the filesystem boundary but is not durable backup content. Excluding it is necessary because an audio FIFO is a live process primitive rather than a regular file, and incomplete uploads or replay builds have no restore value. The backup coordinator must quiesce the owning services before clearing it so exclusion cannot disrupt active work.
An optional smaller **Download settings and state backup** may exclude explicitly regenerable, high-volume snapshots, replay segments, completed replays, and caches. This is secondary; the full backup remains the authoritative complete-server backup.
### Restore
Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation.
The restore service must:
1. Require a lockdown administrator and recent password confirmation.
2. Upload the archive into bounded staging controlled by the data directory.
3. Enforce an upload-size limit that is appropriate for full media-inclusive backups.
4. Reject absolute paths, `..` traversal, symlinks, device files, and unexpected archive structures.
5. Validate the manifest and every checksum before altering active data.
6. Check that the backup version has a supported forward migration path.
7. Display exactly what will be replaced.
8. Require a final explicit confirmation.
9. Record a pending-restore marker.
10. Gracefully stop the application.
11. Apply the restore before ordinary services open their databases on the next start.
12. Run database migrations against the restored data when necessary.
13. Start the application and verify its health.
The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation.
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally.
### Command-line recovery
Backup and restore must also have command-line entry points that use the same implementation as the admin UI. They are needed when the web server cannot start or authentication data is damaged.
The command-line tools must support:
- Creating a consistent backup while the server is stopped
- Validating a backup without applying it
- Restoring while the server is stopped
- Printing a concise manifest summary
- Refusing unsafe or malformed archives
The UI and command line must not develop separate archive formats or validation behavior.
## 7. Standardize graceful application restart
Replace the current host reboot operation with a deployment-neutral **Restart application** operation.
The restart coordinator must:
1. Authorize and acknowledge the request.
2. Stop accepting new persistent mutations.
3. Flush or close persistent stores.
4. Stop MediaMTX, ffmpeg, and native workers.
5. Close HTTP and socket listeners within a bounded timeout.
6. Exit with the status expected by the current supervisor.
During Phase 1, systemd restarts the process. During Phase 2, the container restart policy or lifecycle service restarts it. The browser should show a reconnect state and confirm the active configuration revision after reconnecting.
Host rebooting is a separate privilege and is not part of this application restart contract.
## 8. Internalize MediaMTX WHEP signaling
The current external proxy maps public `/video/<path>` requests to MediaMTX after stripping `/video`. Node should own that mapping directly.
The final request path is:
```text
Browser: /video/<stream>/whep
Node: strips /video and streams the request internally
MediaMTX: /<stream>/whep on 127.0.0.1:8889
```
Required work:
- Add a maintained HTTP proxy library rather than manually reproducing proxy semantics.
- Register the media proxy early enough that request bodies remain unmodified.
- Stream request and response bodies without buffering.
- Forward `POST`, `PATCH`, `DELETE`, authorization, content type, forwarded protocol, and relevant WHEP response headers.
- Apply bounded but media-appropriate proxy timeouts.
- Bind MediaMTX's WHEP listener to loopback.
- Generate browser WHEP URLs relative to the current site origin.
- Remove `media.whepBaseUrl` from configuration.
- Keep public and LAN ICE candidate hostnames as validated admin configuration.
- Test the exact `/video` prefix removal.
- Confirm that MediaMTX's internal HTTP authorization callback still reaches Node.
The actual WebRTC media does not pass through this HTTP proxy. MediaMTX's ICE TCP/UDP port must remain reachable by browsers.
Afterward, the public TLS proxy sends all paths for the site to Node and no longer needs a separate MediaMTX `/video` upstream.
## 9. Phase 1 verification and completion gate
Phase 1 is complete only when all of the following are true:
- The current systemd installation runs without `config.yaml`.
- A completely empty data directory can be initialized through `/setup`.
- An existing YAML installation can be imported exactly once.
- The importer reports unknown or invalid legacy values instead of discarding them.
- All mutable server state is contained by the configured data directory.
- A complete backup can be downloaded and validated.
- A restore replaces the server state only after validation and survives restart.
- Failed restore validation leaves the current server unchanged.
- Configuration, administrator accounts, and secrets survive restart.
- The final lockdown administrator cannot be removed accidentally.
- All administrative surfaces are available through `/admin` with server-side authorization.
- Configuration changes create auditable revisions and apply after restart.
- `/video` works through Node without a special public proxy rule for MediaMTX.
- Rover sockets, RTSP publishing, WHEP playback, snapshots, replays, PTZ, Discord, Home Assistant, Kinect, Balance Board, and reporting retain their intended behavior when enabled.
### Filesystem boundary implementation notes
Implemented on 2026-09-13:
- Removed the obsolete `server/src/data` fallback so there is one default data root.
- Added a shared rover-snapshot directory resolver beneath `SERVER_DATA_DIR`.
- Converted rover snapshot polling, PTZ snapshot reads, and health reporting to that resolver.
- Made the MediaMTX supervisor pass its resolved `SERVER_DATA_DIR` to runOnReady hooks.
- Converted the snapshot writer to require that data root and write to `rover-snapshots` beneath it.
- Removed the separate snapshot and replay-segment locations from the systemd unit generated by the installer.
- Made the installer create and own the canonical data and snapshot directories.
- Preserved the existing flat data layout; established SQLite, JSON, replay, cache, and generated MediaMTX paths were already within the boundary.
- Moved audio-forward FIFOs/uploads and both replay-rendering workspaces from the host temporary directory to `data/runtime`.
- Removed the configurable audio-forward runtime path so configuration cannot direct application writes outside `SERVER_DATA_DIR`.
- Kept prompts, public assets, helper binaries, native workers, and TTS assets with the packaged application because they are read-only application material.
- Left any old `/var/lib/rover-snapshots` and `/var/lib/replay-segments` directories untouched but reported during installation. Nothing reads or writes them after the upgraded service starts, and the operator can remove them after verifying the new paths on the actual server.
Local verification completed:
- Data-path helper tests passed for the default root and an overridden temporary root.
- MediaMTX supervisor testing confirmed that the resolved root reaches child hooks.
- The snapshot writer created `rover-snapshots` beneath a temporary data root and failed closed when no data root was supplied.
- All 91 server tests passed, including the new data-path and MediaMTX supervisor coverage.
- Installer and snapshot-writer shell syntax checks passed.
- Source inventory found no remaining application runtime use of the operating system temporary directory, the old snapshot/replay environment variables, or `/var/lib` paths; only tests use OS temporary directories and the installer retains a deliberate legacy-directory notice.
- Real snapshot generation and legacy-directory cleanup still require verification on the actual server during deployment.
# Phase 2: containerization and image delivery
Phase 2 packages the completed Phase 1 application. It must not introduce a second configuration source or a second persistent-data layout.
## 10. Build the production application image
Use a Fedora-based multi-stage build to remain close to the dependencies already installed by the server installer and to avoid Alpine/musl compatibility problems with native modules and the ChromeOS TTS library.
### Web UI build stage
- Install locked web UI dependencies with `npm ci`.
- Run the production Vite build.
- Copy only the built assets into the final server tree.
### Server dependency stage
- Install locked server dependencies with `npm ci --omit=dev`.
- Supply compiler tooling only in the build stage for native Node modules.
- Copy production dependencies into the final image.
### Native worker stage
- Build the Kinect worker against libfreenect/libusb.
- Build the Balance Board worker against wiiuse/BlueZ.
- Build for the target image architecture rather than copying checked-in workstation binaries.
### Packaged runtime tools
At image-build time:
- Download pinned MediaMTX and neolink releases.
- Verify checksums.
- Install ffmpeg, ffprobe, TTS engines, required GStreamer libraries, and runtime native libraries.
- Install the ChromeOS TTS library and voice data.
- Install the TTS and snapshot helper scripts.
- Run reasonable build-time smoke checks.
The actual server must never download or compile these dependencies during container startup.
### Final image
The final image should:
- Contain no compiler toolchain, Git checkout, development dependencies, or build cache.
- Run Node as a dedicated non-root user.
- Use a minimal init process to reap child processes.
- Treat `/data` as its only persistent writable location.
- Use `/tmp` only for disposable work.
- Include release version and commit metadata.
- Handle `SIGTERM` through the Phase 1 graceful shutdown coordinator.
## 11. Compose deployment
The host-visible installation should be only:
```text
multirover/
├── compose.yaml
└── data/
```
The Compose project contains:
- The main Multirover application container
- A small lifecycle container used for application update and restart
The application mounts:
```text
./data:/data
```
Host networking is the initial preferred design because it most closely preserves current rover RTSP, WebRTC ICE, UDP media, camera, and LAN integration behavior. The exact listeners must be audited before finalizing the Compose file.
Expected externally relevant listeners are:
- Node HTTP, Socket.IO, and proxied WHEP signaling on TCP 8080
- Rover RTSP publishing on TCP 8554
- WebRTC media on TCP and UDP 8189
MediaMTX WHEP on 8889, API/metrics listeners, and server-local SRT should stay on loopback unless an identified remote consumer requires otherwise.
## 12. Hardware access with minimal host setup
The host must still provide the kernel and system services that containers cannot safely configure for themselves.
Kinect requirements:
- One host udev rule granting the intended device access
- The necessary USB device mount, likely `/dev/bus/usb` because Kinect device numbering can change
- libfreenect and the worker inside the image
Balance Board requirements:
- Host Bluetooth daemon configuration required by the existing raw HID design
- Access to the host BlueZ D-Bus socket
- Only the network capabilities required by the native worker
- No fully privileged main application container
The exact capabilities and device permissions must be proven on the real server hardware. This development machine is not the actual server and cannot complete that validation.
The host should not need Node, npm, MediaMTX, ffmpeg, neolink, application source, or a Multirover systemd unit after cutover.
## 13. Health checks
Add an internal health endpoint that verifies:
- Node is accepting requests.
- Configuration initialization and migrations succeeded.
- The data directory is readable and writable.
- Required SQLite databases are usable.
- MediaMTX is running and its internal endpoint responds.
Optional remote integrations should report degraded status to administrators without forcing a container restart loop. Home Assistant, Discord, a camera, or an LLM server being offline does not mean the application process itself is unhealthy.
Compose should use the health endpoint and a restart policy suitable for unattended operation.
## 14. Restricted lifecycle container
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
A small lifecycle container should be the only component with Docker control. It should:
- Have no public network port.
- Accept requests only through a shared Unix socket under `data/system` or another private Compose-only channel.
- Operate only on the fixed Multirover application service.
- Reject arbitrary command lines, service names, image names, and Compose arguments.
- Persist update job state so it survives replacement of the application container.
- Report current version and image digest.
- Pull the configured release image.
- Restart or recreate the application container.
- Wait for the application health check.
- Retain and restore the previous image when the replacement fails.
The `/admin` System section should expose:
- Current version and image digest
- Check for update
- Update and restart
- Restart application
- Update progress and recent output
- Last update result
- Rollback result
These operations require a lockdown administrator and recent password confirmation. The browser must expect its socket to disappear, show a reconnect state, and retrieve the persistent job result after the new application becomes healthy.
The Compose contract should remain stable so ordinary application releases replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
## 15. GHCR release automation
Add repository automation that:
- Builds the production image from a clean checkout.
- Runs server tests, focused web UI tests/lint, and the production web build before publishing.
- Builds each explicitly supported server architecture.
- Publishes immutable commit/release tags to GHCR.
- Publishes one documented stable channel used by the lifecycle updater.
- Records image digests and source revision metadata.
- Avoids publishing when required verification fails.
The deployed server pulls a prebuilt image. It does not run `git pull`, `npm install`, native compilation, or web UI compilation.
## 16. Container cutover
The actual deployment migration should:
1. Download and validate a full Phase 1 backup.
2. Stop and disable the legacy Multirover systemd service.
3. Ensure no legacy MediaMTX service remains active.
4. Place the Compose file beside the existing data directory or move that directory once while the service is stopped.
5. Start the application and lifecycle containers.
6. Confirm that database migrations complete.
7. Confirm the active configuration revision and administrator access.
8. Verify rover connectivity, media publishing, WHEP playback, replay, cameras, and enabled hardware/integrations.
9. Exercise application restart through `/admin`.
10. Exercise an image update and health-check result.
11. Retain the Phase 1 backup until the container deployment has been accepted.
The old systemd application and the Compose application must never run concurrently because they would compete for HTTP and media ports.
## 17. Phase 2 completion gate
Containerization is complete when:
- A new host can start from one Compose file and an empty data directory.
- Existing state can be restored from a Phase 1 full backup.
- `./data:/data` is the only persistent application mount.
- Replacing the application container preserves all state.
- The special external `/video` MediaMTX route is unnecessary.
- The main container has no Docker socket access and is not fully privileged.
- Admin-triggered restart works.
- Admin-triggered update works and persists progress across reconnection.
- A failed image health check rolls back to the prior image.
- GHCR images are reproducibly built from repository releases.
- Kinect and Balance Board behavior has been verified on the actual host.
- Node, npm, application source, and media binaries are no longer installed directly on the host.
# Recommended implementation order
Within the two hard phase boundaries, the safest order is:
- [x] Complete the filesystem audit and single data-directory migration.
- [ ] Add the configuration schema/database and administrator storage.
- [ ] Add first-run setup and the legacy YAML importer.
- [ ] Convert every configuration consumer and remove YAML runtime loading.
- [ ] Build the centralized admin configuration UI.
- [ ] Add persistent audit history.
- [ ] Implement coordinated backup and staged restore.
- [ ] Standardize graceful application restart.
- [ ] Add the internal `/video` proxy and remove the special external route.
- [ ] Run the full Phase 1 completion gate on the legacy deployment.
- [ ] Build and verify the production application image.
- [ ] Add Compose, data mounting, networking, and hardware access.
- [ ] Add GHCR build and publication automation.
- [ ] Add the restricted lifecycle container and connect the System UI.
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
- [ ] Perform the final systemd-to-Compose cutover.
This order gives each invasive change one clear source of failures and leaves the container phase responsible for packaging and supervision rather than unfinished application architecture.
+20 -8
View File
@@ -11,8 +11,6 @@ CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
@@ -30,6 +28,8 @@ fi
TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR"
DATA_DIR="$SERVER_DIR/data"
SNAPSHOT_DIR="$DATA_DIR/rover-snapshots"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml"
@@ -281,10 +281,23 @@ rm -f "$MEDIAMTX_SERVICE"
rm -f /etc/mediamtx/mediamtx.yml
echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
# The repository data directory is the legacy deployment's single persistence
# root and becomes the one bind-mounted /data directory during containerization.
# Create only the snapshot child eagerly because MediaMTX's hook writes there;
# the other services already create their own children when those features run.
mkdir -p "$DATA_DIR" "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$DATA_DIR" "$SNAPSHOT_DIR"
# Previous installers used these two /var/lib directories. Replay code already
# stopped reading its old location, and snapshots regenerate immediately, so do
# not merge possibly stale runtime media over the new canonical data tree. Keep
# an existing directory untouched and report it for deliberate cleanup after the
# operator verifies the upgraded server.
for legacy_dir in /var/lib/rover-snapshots /var/lib/replay-segments; do
if [[ -d "$legacy_dir" ]]; then
echo " Legacy runtime directory is no longer used: $legacy_dir"
fi
done
cat > "$MULTIROVER_SERVICE" <<EOF
[Unit]
Description=Multi-Roomba Rover control server
@@ -297,8 +310,7 @@ Group=$TARGET_USER
WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=SERVER_DATA_DIR=$DATA_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
+10 -1
View File
@@ -5,7 +5,16 @@
set -euo pipefail
PATH_NAME="${MTX_PATH:-}"
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
# Node resolves and supplies SERVER_DATA_DIR when it starts MediaMTX, and
# MediaMTX carries that environment into this runOnReady hook. Requiring that
# single root prevents the writer from silently recreating the former /var/lib
# snapshot store while the readers are looking inside the mounted data folder.
if [[ -z "${SERVER_DATA_DIR:-}" ]]; then
echo "SERVER_DATA_DIR is required for rover snapshot output" >&2
exit 1
fi
SNAP_DIR="${SERVER_DATA_DIR}/rover-snapshots"
# Ignore non-rover-video paths.
case "$PATH_NAME" in
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-C2LCvBX6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CqaMtmWx.css">
<script type="module" crossorigin src="/assets/index-_6Wi5B-Z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
</head>
<body>
<div id="root"></div>
+30 -22
View File
@@ -1,41 +1,49 @@
// data Paths helper
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
const fs = require('fs');
// Purpose: Defines the single filesystem boundary for all mutable, persistent server data.
// Scope: Resolves the configured data root and every application-owned mutable path beneath it.
const path = require('path');
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
function pathExists(target) {
try {
fs.accessSync(target, fs.constants.F_OK);
return true;
} catch (_err) {
return false;
}
}
const ROVER_SNAPSHOT_DIR_NAME = 'rover-snapshots';
const RUNTIME_DIR_NAME = 'runtime';
function resolveDataDir() {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.resolve(configured);
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
return CANONICAL_DATA_DIR;
}
function resolveDataPath(fileName) {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.join(path.resolve(configured), fileName);
/*
Always join through resolveDataDir instead of repeating environment handling
in individual services. This is what makes one SERVER_DATA_DIR mount contain
every database, JSON store, generated file, and persistent media directory.
*/
return path.join(resolveDataDir(), fileName);
}
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
if (pathExists(canonicalPath)) return canonicalPath;
if (pathExists(legacyPath)) return legacyPath;
return canonicalPath;
function resolveRoverSnapshotDir() {
/*
Snapshot production, polling, PTZ reads, and health reporting must use the
exact same directory. Giving this shared directory a named resolver prevents
one of those consumers from drifting back to the former /var/lib location.
*/
return resolveDataPath(ROVER_SNAPSHOT_DIR_NAME);
}
function resolveRuntimePath(...pathSegments) {
/*
Disposable files are still files intentionally managed by the Node server.
Keeping them below a named runtime directory preserves the single-root
filesystem contract without confusing scratch files with durable stores.
Callers remain responsible for deleting their own completed work.
*/
return resolveDataPath(path.join(RUNTIME_DIR_NAME, ...pathSegments));
}
module.exports = {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
};
+49
View File
@@ -0,0 +1,49 @@
// Data Paths Helper Tests
// Purpose: Pins the one-root persistence contract used by local, systemd, and future container deployments.
// Scope: Exercises path resolution only and never creates files in the real server data directory.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('os');
const path = require('path');
const {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
} = require('./dataPaths');
const originalDataDir = process.env.SERVER_DATA_DIR;
test.afterEach(() => {
/*
Environment state is process-global. Restore the caller's value after each
assertion so this focused test remains safe when it is composed with other
tests in the same Node process later.
*/
if (originalDataDir === undefined) delete process.env.SERVER_DATA_DIR;
else process.env.SERVER_DATA_DIR = originalDataDir;
});
test('defaults every persistent path to the canonical server data directory', () => {
delete process.env.SERVER_DATA_DIR;
const expectedRoot = path.resolve(__dirname, '..', '..', 'data');
assert.equal(resolveDataDir(), expectedRoot);
assert.equal(resolveDataPath('identity.sqlite'), path.join(expectedRoot, 'identity.sqlite'));
assert.equal(resolveRoverSnapshotDir(), path.join(expectedRoot, 'rover-snapshots'));
assert.equal(resolveRuntimePath('replay-builds'), path.join(expectedRoot, 'runtime', 'replay-builds'));
});
test('moves every persistent path beneath SERVER_DATA_DIR when it is configured', () => {
const configuredRoot = path.join(os.tmpdir(), 'multirover-data-path-test');
process.env.SERVER_DATA_DIR = configuredRoot;
assert.equal(resolveDataDir(), path.resolve(configuredRoot));
assert.equal(resolveDataPath('fleet-reports.sqlite'), path.join(configuredRoot, 'fleet-reports.sqlite'));
assert.equal(resolveDataPath(path.join('replays', 'example.mp4')), path.join(configuredRoot, 'replays', 'example.mp4'));
assert.equal(resolveRoverSnapshotDir(), path.join(configuredRoot, 'rover-snapshots'));
assert.equal(
resolveRuntimePath('audio-forward', 'uploads'),
path.join(configuredRoot, 'runtime', 'audio-forward', 'uploads'),
);
});
@@ -6,6 +6,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
@@ -25,7 +26,13 @@ const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
/*
FIFOs and uploaded clips are disposable, but they are deliberately created
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
Node process from writing to an unrelated host temp directory and prevents a
configuration value from escaping the server's filesystem boundary.
*/
const runtimeDir = resolveRuntimePath('audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
+2 -1
View File
@@ -3,12 +3,13 @@
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fsp = require('fs/promises');
const path = require('path');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService');
const { getReplayHealthSnapshot } = require('../replayEngineV2');
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_STALE_MS = 5000;
@@ -5,7 +5,7 @@ const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const yaml = require('js-yaml');
const { resolveDataPath } = require('../../helpers/dataPaths');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { buildMediaMtxConfig } = require('./config');
function createMediaMtxSupervisor(deps) {
@@ -52,6 +52,16 @@ function createMediaMtxSupervisor(deps) {
logger.info(`Starting MediaMTX with generated config ${configPath}`);
child = spawnProcess(mediaMtxBin, [configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
/*
MediaMTX passes its environment to runOnReady hooks. Supplying the
resolved value here also covers development starts where
SERVER_DATA_DIR was omitted, so the installed snapshot writer and every
Node snapshot reader still converge on the same canonical data root.
*/
env: {
...process.env,
SERVER_DATA_DIR: resolveDataDir(),
},
});
forwardLines(child.stdout, 'info');
@@ -0,0 +1,66 @@
// MediaMTX Supervisor Tests
// Purpose: Verifies that generated MediaMTX state and child hooks inherit the server's single data root.
// Scope: Uses a child-process double and a temporary directory; no listener or background process is started.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const EventEmitter = require('events');
const { PassThrough } = require('stream');
const { createMediaMtxSupervisor } = require('./supervisor');
test('passes the resolved data root to MediaMTX runOnReady hooks', () => {
const temporaryDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-mediamtx-supervisor-'));
const generatedConfigPath = path.join(temporaryDataDir, 'mediamtx.yml');
const previousDataDir = process.env.SERVER_DATA_DIR;
let invocation = null;
process.env.SERVER_DATA_DIR = temporaryDataDir;
const spawnProcess = (command, args, options) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = (signal) => {
child.emit('exit', 0, signal);
};
invocation = { command, args, options };
return child;
};
const logger = {
info() {},
warn() {},
error() {},
};
try {
const supervisor = createMediaMtxSupervisor({
config: { media: { additionalHosts: ['media.example.test'] } },
serverPort: 8080,
logger,
mediaMtxBin: '/test/bin/mediamtx',
snapshotWriterPath: '/test/bin/rover-snapshot-writer',
configPath: generatedConfigPath,
spawnProcess,
});
supervisor.start();
assert.equal(invocation.command, '/test/bin/mediamtx');
assert.deepEqual(invocation.args, [generatedConfigPath]);
assert.equal(invocation.options.env.SERVER_DATA_DIR, temporaryDataDir);
assert.equal(fs.existsSync(generatedConfigPath), true);
supervisor.stop();
} finally {
/*
Restore process-global state and delete only the test-owned directory so a
failed assertion cannot alter later tests or leave generated YAML behind.
*/
if (previousDataDir === undefined) delete process.env.SERVER_DATA_DIR;
else process.env.SERVER_DATA_DIR = previousDataDir;
fs.rmSync(temporaryDataDir, { recursive: true, force: true });
}
});
@@ -10,6 +10,7 @@ const { Cam } = require('onvif');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('ptzCamera');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const { isFeatureEnabled } = require('../../helpers/features');
const {
shouldUseSnapshotsForNonTurnVideo,
@@ -43,7 +44,7 @@ const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
// explicitly disables replay for the camera.
const DEFAULT_REPLAY_ENABLED = true;
const DEFAULT_PTZ_COLOR = '#387bf8';
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const SNAPSHOT_DIR = resolveRoverSnapshotDir();
const SNAPSHOT_POLL_MS = 300;
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
@@ -1,8 +1,8 @@
// Replay Builder Pipeline
// Purpose: Assembles selected buffered segments into final replay output video with optional sidebar.
// Scope: Owns concat/probe/layout/transcode pipeline and returns replay buffer plus source usage metadata.
const os = require('os');
const path = require('path');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService');
const { getRecentMessages } = require('../chatService');
@@ -124,7 +124,15 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
durationMs: BUILD_DURATION_MS,
});
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
/*
A replay build is disposable, but every intermediate concat list, pinned
segment, and ffmpeg output is created on the server's behalf. Create a
unique workspace below SERVER_DATA_DIR so the application never spills
those writes into the host-wide temporary directory.
*/
const buildRoot = resolveRuntimePath('replay-builds');
await ensureDir(buildRoot);
const tmpDir = await fsp.mkdtemp(path.join(buildRoot, 'build-'));
try {
const usedSources = [];
@@ -4,11 +4,11 @@
const { execFile } = require('child_process');
const EventEmitter = require('events');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const { promisify } = require('util');
const logger = require('../../globals/logger').child('roomCameraReplay');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const execFileAsync = promisify(execFile);
@@ -118,7 +118,15 @@ async function buildRoomCameraReplayVideo({ cameraId = null } = {}, { getRoomCam
});
if (!cameraEntries.length) throw new Error('No camera frames available yet');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
/*
Hundreds of frame images can be produced for one room-camera replay. They
are temporary, but the Node application owns them, so both the workspace
and final intermediate video stay under the configured data root until the
existing finally block removes them.
*/
const buildRoot = resolveRuntimePath('room-camera-replay-builds');
await fsp.mkdir(buildRoot, { recursive: true });
const tmpDir = await fsp.mkdtemp(path.join(buildRoot, 'build-'));
try {
const firstFramePaths = [];
for (let i = 0; i < cameraEntries.length; i += 1) {
@@ -184,6 +184,11 @@ function createRoverLifecycle(deps) {
const currentRecord = rovers.get(currentId);
if (!currentRecord) return { ok: true, currentId };
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
// HELP means the normal dock-before-leaving requirement has failed to
// resolve the rover's situation and a person may need to take a different
// rover instead. This exception changes departure only; request eligibility
// and automatic assignment ranking remain owned by their existing paths.
if (currentRecord.needsHelp) return { ok: true, currentId };
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
}
@@ -57,3 +57,44 @@ test('removing a rover clears driver sets, reverse membership, rooms, and turns'
{ socketId, roverId, action: 'remove' },
]);
});
test('the last driver may leave an undocked HELP rover but not an ordinary undocked rover', () => {
const roverId = 'rover-help';
const socketId = 'driver-help';
const socket = { id: socketId };
const record = {
id: roverId,
drivers: new Set([socketId]),
needsHelp: false,
// A present but explicitly non-charging frame exercises the real policy
// boundary instead of accidentally passing through missing rover state.
lastSensor: {
decoded: {
chargingSources: { homeBase: false },
chargingState: { code: 0 },
},
},
};
const lifecycle = createRoverLifecycle({
io: { sockets: { sockets: new Map() } },
rovers: new Map([[roverId, record]]),
socketToRovers: new Map([[socketId, new Set([roverId])]]),
managerEvents: new EventEmitter(),
turnService: {},
isAdmin: () => false,
sendAlert: () => {},
ALERT_COLOR: '#000000',
getMode: () => 'public',
getControlDenialReason: () => null,
});
const ordinaryResult = lifecycle.canLeaveCurrentRover(socket);
assert.equal(ordinaryResult.ok, false);
assert.equal(ordinaryResult.message, 'Dock and charge your current rover before switching.');
// Mutating only the server-owned HELP flag proves that no docking, driver,
// role, or assignment condition is being weakened as part of the exception.
record.needsHelp = true;
const helpResult = lifecycle.canLeaveCurrentRover(socket);
assert.deepEqual(helpResult, { ok: true, currentId: roverId });
});
@@ -5,8 +5,9 @@ const EventEmitter = require('events');
const fs = require('fs/promises');
const path = require('path');
const logger = require('../../globals/logger').child('roverSnapshot');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const SNAPSHOT_DIR = resolveRoverSnapshotDir();
const POLL_INTERVAL_MS = 300;
const roverState = new Map();
const events = new EventEmitter();
+3 -5
View File
@@ -4,11 +4,9 @@ research how midis can be played easier with drag and drop, auto selection based
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
```
1. setting to disable replay popups in spectator settings menu
2. improve controller support for bignuts700
3. add admin ui for VIP and private requests instead of only through discord
4. automated rover needs help system
5. make roverd self update checkout to main branch
6. fix this:
2. add admin ui for VIP and private requests instead of only through discord
3. make roverd self update checkout to main branch
4. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^
+290 -11
View File
@@ -8,6 +8,7 @@
"name": "webui",
"version": "0.0.0",
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
@@ -34,7 +35,8 @@
"postcss": "^8.5.6",
"socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14",
"vite": "^7.2.2"
"vite": "^7.2.2",
"vitest": "^5.0.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1034,9 +1036,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -1051,6 +1053,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lizardbyte/gamepad-helper": {
"version": "2026.816.4539",
"resolved": "https://registry.npmjs.org/@lizardbyte/gamepad-helper/-/gamepad-helper-2026.816.4539.tgz",
"integrity": "sha512-Tncd9+MEUOU9JpDnJIeFEDlUure8CGItLHbcr08i14uylUtx20oqWnTR+ZGmVITeGjdqW9+a43FSMJUiK0c4LA==",
"license": "MIT",
"funding": {
"url": "https://app.lizardbyte.dev"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1483,6 +1494,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1550,6 +1579,44 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1667,6 +1734,16 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/autoprefixer": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
@@ -1834,6 +1911,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2061,6 +2148,13 @@
"node": ">=10.0.0"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2300,6 +2394,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2310,6 +2414,16 @@
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2915,6 +3029,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
"integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3073,6 +3197,20 @@
"node": ">= 6"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3208,9 +3346,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3727,6 +3865,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -3780,6 +3925,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -4013,15 +4172,35 @@
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -4201,6 +4380,89 @@
}
}
},
"node_modules/vitest": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/web-haptics": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz",
@@ -4243,6 +4505,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+4 -1
View File
@@ -7,9 +7,11 @@
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"build": "vite build",
"lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
"preview": "vite preview"
},
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
@@ -36,6 +38,7 @@
"postcss": "^8.5.6",
"socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14",
"vite": "^7.2.2"
"vite": "^7.2.2",
"vitest": "^5.0.0"
}
}
+3 -3
View File
@@ -68,8 +68,8 @@ export default function CardFrame({
? { borderColor: '#008a35' }
: accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
// backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.1)} 100%)`,
// backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.2)} 100%)`,
// backgroundImage: `background-color: ${rgba(accentRgb, 0.2)}`
}
: undefined;
@@ -100,7 +100,7 @@ export default function CardFrame({
>
<div className="flex min-w-0 items-center gap-0.5">
{title ? (
<p className={cx('m-0 text-[0.78rem] font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
<p className={cx('m-0 font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
{title}
</p>
) : null}
@@ -0,0 +1,9 @@
// Adaptive Control Hint
// Purpose: Renders the binding for a logical action using the user's most recently used input type.
// Scope: Keeps the render component separate from its hook so React fast refresh can safely
// replace this module without treating a non-component export as component state.
import { useControlHintLabel } from './useControlHintLabel.js';
export default function ControlHint({ actionId }) {
return <>{useControlHintLabel(actionId)}</>;
}
@@ -0,0 +1,31 @@
// Adaptive Control Hint Label Hook
// Purpose: Resolves a logical action to the keyboard or controller label appropriate for the
// operator's most recently used input device.
// Scope: Reads control/settings state only; it never captures input or dispatches rover commands.
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
import { formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
import { resolveGamepadProfile } from '../../controls/inputs/gamepadBindings.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
export function useControlHintLabel(actionId) {
const keyValue = useControlSelector((control) => control.state.keymap?.[actionId]?.[0]);
const runtime = useControllerRuntime();
const { value: gamepadSettings } = useSettingsNamespace('gamepad', GAMEPAD_SETTINGS_DEFAULTS);
if (runtime.inputMethod !== 'controller' || !runtime.controller) {
return formatKeyLabel(keyValue);
}
/* Profiles remain keyed by reusable hardware signature, while runtime controller selection is
instance-specific. This lets two identical connected pads share a mapping without losing the
browser slot used to decide which one currently owns control. */
const storedProfile =
gamepadSettings?.profiles?.[runtime.controller.signature] ??
gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT;
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
return formatControllerBinding(profile, actionId, runtime.controller);
}
@@ -6,7 +6,7 @@ import '../MobileControls/mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -90,7 +90,6 @@ export default function DriveDockAction({
}) {
const isMobile = layout === 'mobile';
const roverId = useControlSelector((control) => control.state.roverId);
const keymap = useControlSelector((control) => control.state.keymap);
const actions = useControlActions();
const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
@@ -104,8 +103,8 @@ export default function DriveDockAction({
const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null;
const driveKeyLabel = formatKeyLabel(keymap?.driveMacro?.[0]);
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
const driveKeyLabel = <ControlHint actionId="driveMacro" />;
const dockKeyLabel = <ControlHint actionId="dockMacro" />;
const dockInstructions = {
summary: 'Use assist mode to manually line up with the dock.',
@@ -7,13 +7,15 @@ import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settin
import {
computeGamepadOutputs,
createProfileForPad,
resolveGamepadProfile,
} from '../../controls/inputs/gamepadBindings.js';
import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js';
import { acquireControllerControlLock } from '../../controls/inputs/controllerRuntime.js';
import { describeController, formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
import CardFrame from '../CardFrame/index.jsx';
import SliderField from './SliderField.jsx';
import { ACTIONS, NUMBER_FORMAT } from './constants.js';
import {
formatSource,
groupActions,
pickActivePad,
snapshotBaseline,
@@ -26,21 +28,63 @@ function SettingsGroupLabel({ children }) {
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
}
function physicalInputKey(source) {
/* Inversion and activation thresholds describe how an input is interpreted, not which physical
control it is. Ignoring those fields ensures Axis 3 cannot silently own both a tank track and
camera tilt merely because one binding happens to be inverted. */
if (source?.kind === 'axis' || source?.kind === 'axisButton') return `axis:${source.index}`;
if (source?.kind === 'button' || source?.kind === 'buttonAxis') return `button:${source.index}`;
return JSON.stringify(source);
}
function sourcesUseSamePhysicalInput(left, right) {
if (!left || !right) return false;
return physicalInputKey(left) === physicalInputKey(right);
}
function actionDriveMode(actionId) {
return ACTIONS.find((action) => action.id === actionId)?.driveMode ?? null;
}
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 +94,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 +102,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 +115,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 +124,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,25 +151,48 @@ 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);
useEffect(() => {
/* The settings panel remains a live control surface so operators can tune calibration while
driving and immediately feel the result. Only capture owns the controller lock: without
that narrow guard, pressing the input being assigned could also drive a wheel, start a
motor, or toggle rover hardware before the new binding is saved. */
if (!captureAction) return undefined;
return acquireControllerControlLock('controller-binding-capture');
}, [captureAction]);
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]);
const driveMode = activeProfile.calibration?.driveMode === 'tank' ? 'tank' : 'single';
const grouped = useMemo(() => {
const query = actionFilter.trim().toLowerCase();
/* Only the active steering scheme is shown. Keeping inactive track/stick bindings out of the
mapping list prevents operators from tuning controls that currently have no runtime effect. */
const modeActions = ACTIONS.filter(
(action) => !action.driveMode || action.driveMode === driveMode,
);
const visibleActions = query
? modeActions.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
: modeActions;
return groupActions(visibleActions);
}, [actionFilter, driveMode]);
useEffect(() => {
if (!activePad || !activeSignature) return;
@@ -132,7 +200,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 +214,7 @@ export default function GamepadMappingSettings() {
useEffect(() => {
baselineRef.current = null;
captureCandidateRef.current = null;
}, [captureAction, activeSignature]);
useEffect(() => {
@@ -154,16 +223,49 @@ 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 bindingsWithoutConflict = Object.fromEntries(
Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => {
if (actionId === captureAction.id) return [actionId, binding];
const otherMode = actionDriveMode(actionId);
const captureMode = actionDriveMode(captureAction.id);
/* Opposing mode-only actions may intentionally reuse a physical input because runtime
never activates them together. Common actions still conflict with both modes. */
if (captureMode && otherMode && captureMode !== otherMode) return [actionId, binding];
const sources = (binding?.sources ?? []).filter(
(source) => !sourcesUseSamePhysicalInput(source, descriptor),
);
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 +281,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 +304,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 +336,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 +442,74 @@ 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 === 'tankLeft') return Math.abs(outputs.tankTracks?.left ?? 0) > 0.01;
if (actionId === 'tankRight') return Math.abs(outputs.tankTracks?.right ?? 0) > 0.01;
if (actionId === 'tankCameraUp' || actionId === 'tankCameraDown') {
return Boolean(outputs.buttons[actionId]);
}
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 +519,42 @@ 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>
<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-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Steering mode</span>
<select
value={driveMode}
onChange={(event) => {
updateCalibration({ driveMode: event.target.value });
setCaptureAction(null);
}}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="single">Single stick</option>
<option value="tank">Tank sticks</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">
Tank mode controls the left and right wheels with separate stick axes.
</p>
</label>
<SliderField
label="Drive deadzone"
description="Ignore small drive stick drift"
@@ -351,15 +564,50 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.driveDeadzone ?? 0.18}
onChange={(value) => updateCalibration({ driveDeadzone: value })}
/>
<SliderField
label="Camera deadzone"
description="Ignore small camera tilt drift"
min={0}
max={0.4}
step={0.01}
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
onChange={(value) => updateCalibration({ cameraDeadzone: 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 })}
/>
{driveMode === 'single' && (
<>
{/* Analog-only settings are hidden in tank mode because its two D-pad camera
directions are digital velocity inputs. The values remain saved for when the
operator returns to single-stick steering. */}
<SliderField
label="Velocity camera deadzone"
description="Absolute mode always uses a 0.01 deadzone"
min={0}
max={0.4}
step={0.01}
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 +617,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,22 +631,33 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.auxSideScale ?? 0.55}
onChange={(value) => updateCalibration({ auxSideScale: 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'}
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"
>
<option value="absolute">Absolute</option>
<option value="velocity">Velocity</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
</label>
<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 })}
/>
{driveMode === 'single' && (
<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 ?? '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"
>
<option value="absolute">Absolute</option>
<option value="velocity">Velocity</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
</label>
)}
<SliderField
label="Camera sensitivity"
description="Velocity mode degrees per second"
@@ -407,6 +671,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 +695,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 +711,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 +753,8 @@ export default function GamepadMappingSettings() {
</div>
</>
)}
</div>
</div>
</details>
)}
</div>
</CardFrame>
@@ -10,6 +10,23 @@ export const ACTIONS = [
kind: 'axisPair',
section: 'Driving',
invertDefaults: { invertX: false, invertY: true },
driveMode: 'single',
},
{
id: 'tankLeft',
label: 'Left track',
kind: 'axis',
section: 'Driving',
invertDefaults: { invert: true },
driveMode: 'tank',
},
{
id: 'tankRight',
label: 'Right track',
kind: 'axis',
section: 'Driving',
invertDefaults: { invert: true },
driveMode: 'tank',
},
{
id: 'cameraTilt',
@@ -17,7 +34,10 @@ export const ACTIONS = [
kind: 'axis',
section: 'Camera',
invertDefaults: { invert: true },
driveMode: 'single',
},
{ id: 'tankCameraUp', label: 'Camera up', kind: 'button', section: 'Camera', driveMode: 'tank' },
{ id: 'tankCameraDown', label: 'Camera down', kind: 'button', section: 'Camera', driveMode: 'tank' },
{
id: 'mainBrush',
label: 'Main brush',
@@ -32,14 +52,33 @@ export const ACTIONS = [
section: 'Brushes',
invertDefaults: { invert: false },
},
{ id: 'vacuum', label: 'Vacuum', kind: 'button', section: 'Aux buttons' },
{ id: 'allAux', label: 'All aux', kind: 'button', section: 'Aux buttons' },
{ id: 'vacuum', label: 'Vacuum only', kind: 'button', section: 'Aux buttons' },
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' },
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
{ id: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
{ id: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
{ 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: 'Play higher note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'songNoteDown', label: 'Play lower note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'homeAssistantOn', label: 'Turn next room control on', kind: 'button', section: 'Room controls' },
{ id: 'homeAssistantOff', label: 'Turn next room control off', kind: 'button', section: 'Room controls' },
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
{ id: 'auxMainReverse', label: 'Main brush reverse', kind: 'button', section: 'Aux buttons' },
{ id: 'auxSideForward', label: 'Side brush forward', kind: 'button', section: 'Aux buttons' },
{ id: 'auxSideReverse', label: 'Side brush reverse', kind: 'button', section: 'Aux buttons' },
{ id: 'auxVacuumFast', label: 'Vacuum max', kind: 'button', section: 'Aux buttons' },
{ id: 'auxVacuumSlow', label: 'Vacuum low', kind: 'button', section: 'Aux buttons' },
{ id: 'auxAllForward', label: 'All motors forward', kind: 'button', section: 'Aux buttons' },
];
export const CAPTURE_AXIS_THRESHOLD = 0.45;
@@ -33,10 +33,10 @@ export function groupActions(actions) {
}, {});
}
export function pickActivePad(pads, activeSignature) {
export function pickActivePad(pads, activeInstanceKey) {
if (!pads || pads.length === 0) return null;
if (activeSignature) {
const match = pads.find((pad) => pad.signature === activeSignature);
if (activeInstanceKey) {
const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
if (match) return match;
}
return pads[0];
@@ -62,10 +62,13 @@ function detectAxisCapture(pad, baseline, action) {
if (action.kind === 'axisPair') {
const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2);
if (top.length < 2) return null;
const orderedIndices = top.map((entry) => entry.index).sort((a, b) => a - b);
return {
kind: 'axisPair',
x: top[0].index,
y: top[1].index,
// Browsers expose two-dimensional controls as adjacent X/Y axes. Sorting the captured pair
// prevents whichever direction moved first from randomly swapping steering and throttle.
x: orderedIndices[0],
y: orderedIndices[1],
...(action.invertDefaults ?? {}),
};
}
@@ -80,16 +83,25 @@ function detectAxisCapture(pad, baseline, action) {
function detectButtonCapture(pad, baseline, action) {
const buttons = pad.buttons ?? [];
const newlyPressed = [];
for (let i = 0; i < buttons.length; i += 1) {
const btn = buttons[i];
const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0;
if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) {
const baselineValue = baseline.buttons?.[i]?.value ?? 0;
const baselinePressed = baseline.buttons?.[i]?.pressed ?? false;
if (!baselinePressed && (btn?.pressed || value - baselineValue > CAPTURE_BUTTON_THRESHOLD)) {
if (action.kind === 'axis') {
return { kind: 'buttonAxis', index: i };
}
return { kind: 'button', index: i };
newlyPressed.push({ kind: 'button', index: i });
}
}
if (newlyPressed.length > 1) {
// Capturing all buttons observed in the same frame makes intentional modifier chords possible
// without a separate advanced editor, while a normal single press keeps the compact shape.
return { kind: 'chord', inputs: newlyPressed };
}
if (newlyPressed.length === 1) return newlyPressed[0];
const axes = pad.axes ?? [];
for (let i = 0; i < axes.length; i += 1) {
const value = axes[i] ?? 0;
@@ -0,0 +1,49 @@
// Controller Capture Tests
// Purpose: Verifies that binding capture cannot select held controls or swap stick axes randomly.
// Scope: Covers the pure capture detector used by the Controller settings surface.
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildDescriptorFromCapture, snapshotBaseline } from './helpers.js';
function button(pressed = false, value = pressed ? 1 : 0) {
return { pressed, value };
}
test('a button held before capture is ignored', () => {
const baselinePad = { axes: [0, 0], buttons: [button(true), button(false)] };
const currentPad = { axes: [0, 0], buttons: [button(true), button(false)] };
const descriptor = buildDescriptorFromCapture(
currentPad,
snapshotBaseline(baselinePad),
{ kind: 'button' },
);
assert.equal(descriptor, null);
});
test('axis-pair capture assigns the lower adjacent axis to X regardless of movement order', () => {
const baseline = snapshotBaseline({ axes: [0, 0, 0, 0], buttons: [] });
const descriptor = buildDescriptorFromCapture(
{ axes: [0, 0, -0.7, 0.9], buttons: [] },
baseline,
{ kind: 'axisPair', invertDefaults: { invertY: true } },
);
assert.deepEqual(descriptor, {
kind: 'axisPair',
x: 2,
y: 3,
invertY: true,
});
});
test('simultaneous new buttons are represented as a chord', () => {
const baseline = snapshotBaseline({ axes: [], buttons: [button(), button(), button()] });
const descriptor = buildDescriptorFromCapture(
{ axes: [], buttons: [button(true), button(), button(true)] },
baseline,
{ kind: 'button' },
);
assert.deepEqual(descriptor, {
kind: 'chord',
inputs: [{ kind: 'button', index: 0 }, { kind: 'button', index: 2 }],
});
});
+12 -6
View File
@@ -2,14 +2,14 @@
// Purpose: Defines the Help Content View module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
import { getHelpContent } from '../../help/content.js';
function KeyPill({ actionId, keymap }) {
const value = keymap?.[actionId]?.[0] ?? '';
function KeyPill({ actionId }) {
return (
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
{formatKeyLabel(value)}
<ControlHint actionId={actionId} />
</span>
);
}
@@ -130,14 +130,20 @@ function KeyboardGroup({ group, keymap }) {
}
function KeyboardBlock({ block, keymap }) {
const runtime = useControllerRuntime();
if (!block) return null;
const usingController = runtime.inputMethod === 'controller';
return (
<div className="space-y-0.5">
{/* Heading and footnote share a row when possible and wrap independently
when the Help card is mounted in a narrow desktop column. */}
<div className="flex flex-wrap items-center justify-between gap-0.5 text-xs text-slate-200">
<span className="font-semibold">{block.title}</span>
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
<span className="font-semibold">{usingController ? 'Controller controls' : block.title}</span>
<span className="text-[0.7rem] text-slate-400">
{usingController
? 'Per-controller; adjust bindings in Settings → Controller.'
: block.footnote}
</span>
</div>
{/* Two keyboard groups fit comfortably once the Help surface reaches 32rem.
Using the real content threshold restores the established old-page layout
@@ -3,8 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
@@ -184,7 +183,6 @@ export default function HomeAssistantControls() {
}
function HomeAssistantControlsContent() {
const keymap = useControlSelector((control) => control.state.keymap);
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions();
@@ -196,8 +194,8 @@ function HomeAssistantControlsContent() {
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
const onKeyLabel = <ControlHint actionId="homeAssistantOn" />;
const offKeyLabel = <ControlHint actionId="homeAssistantOff" />;
if (!ha?.enabled) {
return (
@@ -3,7 +3,7 @@
import { useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import ControlHint from '../../../ControlHint/index.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
@@ -45,7 +45,6 @@ export default function BottomLeftPod({ roverId }) {
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const canControl = useCanControlRover(roverId);
const hornPointerRef = useRef(null);
@@ -80,13 +79,13 @@ export default function BottomLeftPod({ roverId }) {
{/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div>
) : (
@@ -3,7 +3,7 @@
import { useCallback } from 'react';
import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import ControlHint from '../../../ControlHint/index.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
@@ -32,7 +32,6 @@ export default function BottomRightPod({ roverId }) {
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setServoAngle } = useControlActions();
const canControl = useCanControlRover(roverId);
const config = camera?.config;
@@ -86,8 +85,8 @@ export default function BottomRightPod({ roverId }) {
</button>
{/* These positions continue around the same circle just beyond the two slider endpoints.
Together they occupy the open third facing the corner without enlarging the pod. */}
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraDown" />} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraUp" />} /></div>
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
</div>
) : showCameraControls && enabled ? (
@@ -4,14 +4,12 @@ import { useCallback, useState } from 'react';
import { FaComment } from 'react-icons/fa';
import { useChatActions } from '../../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import ControlHint from '../../../ControlHint/index.jsx';
import HudChatInput from '../../HudChatInput/index.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function ChatExpansion({ podOpen }) {
const role = useSessionSelector((state) => state.session?.role || null);
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
const { blurChat, focusChat } = useChatActions();
const [open, setOpen] = useState(false);
@@ -50,7 +48,7 @@ export default function ChatExpansion({ podOpen }) {
<FaComment aria-hidden="true" />
{/* The pill reflects the live keymap so remapping chat focus updates this
compact HUD hint without duplicating or hardcoding the default key. */}
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
<KeyPill label={<ControlHint actionId="chatFocus" />} />
</button>
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
@@ -1,7 +1,7 @@
// Top-right Corner Pod
// Purpose: Combines the battery/current gauge with a compact attached advanced-power expansion.
import { createElement, useMemo } from 'react';
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaMicrochip, FaThermometerHalf, FaWifi } from 'react-icons/fa';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
@@ -19,6 +19,20 @@ function clampPercent(value) {
return number == null ? 0 : Math.max(0, Math.min(100, number));
}
function formatMemoryUsage(totalKb, availableKb, usedPercent) {
const total = finite(totalKb);
const available = finite(availableKb);
const percent = finite(usedPercent);
if (total == null || available == null || percent == null) return percent == null ? '--' : `${Math.round(percent)}%`;
const used = Math.max(0, total - available);
const useGigabytes = total >= 1024 * 1024;
const divisor = useGigabytes ? 1024 * 1024 : 1024;
const decimals = useGigabytes ? 1 : 0;
const unit = useGigabytes ? 'GB' : 'MB';
return `${Math.round(percent)}% · ${(used / divisor).toFixed(decimals)}/${(total / divisor).toFixed(decimals)} ${unit}`;
}
function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
return (
<div className="min-w-0" title={label}>
@@ -27,9 +41,6 @@ function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
</div>
{/* Every meter uses an explicit real-world display range defined by its caller. The bar
therefore adds information instead of merely decorating the latest numeric value.
Keeping it on its own line gives both the title and meter the full panel width. */}
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700">
<div className={`h-full rounded-full ${fillClass}`} style={{ width: `${clampPercent(percent)}%` }} />
</div>
@@ -37,7 +48,7 @@ function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
);
}
function WifiTile({ signal }) {
function WifiTile({ signal, ssid }) {
const bars = signal == null ? 0 : signal >= -55 ? 4 : signal >= -65 ? 3 : signal >= -75 ? 2 : 1;
const tone = signal == null ? 'bg-slate-600' : signal < -80 ? 'bg-red-400' : signal < -70 ? 'bg-amber-400' : 'bg-emerald-400';
return (
@@ -47,6 +58,7 @@ function WifiTile({ signal }) {
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">Wi-Fi signal</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{signal == null ? '--' : `${Math.round(signal)} dBm`}</strong>
</div>
<div className="mt-1 truncate text-[0.68rem] font-semibold leading-none text-white">Wi-Fi: {ssid || '--'}</div>
<div className="mt-1 flex h-2 items-end gap-0.5" aria-hidden="true">
{[1, 2, 3, 4].map((bar) => (
<span key={bar} className={`flex-1 rounded-sm ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
@@ -98,11 +110,14 @@ export default function TopRightPod({ roverId }) {
const batteryCharge = finite(electrical?.batteryChargeMah);
const batteryCapacity = finite(electrical?.batteryCapacityMah);
const cpuTemp = finite(host?.cpuTempC);
const cpuUsed = finite(host?.cpuUsedPct);
const memoryUsed = finite(host?.memoryUsedPct);
const memoryUsage = formatMemoryUsage(host?.memoryTotalKb, host?.memoryAvailableKb, memoryUsed);
const voltagePercent = voltage == null ? 0 : ((voltage - 12000) / 5000) * 100;
const batteryMahPercent = batteryCharge != null && batteryCapacity > 0 ? (batteryCharge / batteryCapacity) * 100 : 0;
const cpuTempPercent = cpuTemp == null ? 0 : ((cpuTemp - 30) / 55) * 100;
const cpuTempTone = cpuTemp >= 80 ? 'bg-red-400' : cpuTemp >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
const cpuTone = cpuUsed >= 90 ? 'bg-red-400' : cpuUsed >= 70 ? 'bg-amber-400' : 'bg-sky-400';
const memoryTone = memoryUsed >= 90 ? 'bg-red-400' : memoryUsed >= 75 ? 'bg-amber-400' : 'bg-violet-400';
const download = finite(wifi.downloadMbps);
const upload = finite(wifi.uploadMbps);
@@ -154,10 +169,11 @@ export default function TopRightPod({ roverId }) {
<div className="space-y-1.5">
<MetricRow icon={FaBolt} label="Roomba voltage" value={voltage == null ? '--' : `${(voltage / 1000).toFixed(1)} V`} percent={voltagePercent} iconClass="text-sky-300" fillClass="bg-sky-400" />
<MetricRow icon={FaBolt} label="Roomba current" value={`${current > 0 ? '+' : ''}${Math.round(current)} mA`} percent={currentPercent * 100} iconClass={current < 0 ? 'text-amber-300' : 'text-emerald-300'} fillClass={current < 0 ? 'bg-amber-400' : 'bg-emerald-400'} />
<MetricRow icon={FaBatteryHalf} label="Battery charge" value={batteryCharge == null ? '--' : `${Math.round(batteryCharge)} mAh`} percent={batteryMahPercent} iconClass="text-emerald-300" fillClass="bg-emerald-400" />
<MetricRow icon={FaBatteryHalf} label="Battery charge" value={batteryCharge == null || batteryCapacity == null ? '--' : `${Math.round(batteryCharge)} / ${Math.round(batteryCapacity)} mAh`} percent={batteryMahPercent} iconClass="text-emerald-300" fillClass="bg-emerald-400" />
<MetricRow icon={FaThermometerHalf} label="Computer temperature" value={cpuTemp == null ? '--' : `${cpuTemp.toFixed(1)} C`} percent={cpuTempPercent} iconClass={cpuTemp >= 80 ? 'text-red-300' : cpuTemp >= 70 ? 'text-amber-300' : 'text-emerald-300'} fillClass={cpuTempTone} />
<MetricRow icon={FaMemory} label="Memory usage" value={memoryUsed == null ? '--' : `${Math.round(memoryUsed)}%`} percent={memoryUsed} iconClass={memoryUsed >= 90 ? 'text-red-300' : memoryUsed >= 75 ? 'text-amber-300' : 'text-violet-300'} fillClass={memoryTone} />
<WifiTile signal={signal} />
<MetricRow icon={FaMicrochip} label="CPU usage" value={cpuUsed == null ? '--' : `${Math.round(cpuUsed)}%`} percent={cpuUsed} iconClass={cpuUsed >= 90 ? 'text-red-300' : cpuUsed >= 70 ? 'text-amber-300' : 'text-sky-300'} fillClass={cpuTone} />
<MetricRow icon={FaMemory} label="Memory usage" value={memoryUsage} percent={memoryUsed} iconClass={memoryUsed >= 90 ? 'text-red-300' : memoryUsed >= 75 ? 'text-amber-300' : 'text-violet-300'} fillClass={memoryTone} />
<WifiTile signal={signal} ssid={wifi.ssidSample} />
<SpeedTile icon={FaArrowDown} label="Download speed" value={download == null ? '--' : `${download.toFixed(1)} Mb/s`} colorClass="text-sky-300" />
<SpeedTile icon={FaArrowUp} label="Upload speed" value={upload == null ? '--' : `${upload.toFixed(1)} Mb/s`} colorClass="text-violet-300" />
</div>
@@ -4,8 +4,8 @@
// the archived desktop layout retains its previous DriveDockAction behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaChargingStation } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import { useControlActions } from '../../../../controls/index.js';
import ControlHint from '../../../ControlHint/index.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
@@ -252,7 +252,6 @@ function UndockTransitionGhost({ onFinish }) {
export default function DockingHud({ roverId }) {
const layout = useDriverLayout();
const actions = useControlActions();
const keymap = useControlSelector((control) => control.state.keymap);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
@@ -282,12 +281,8 @@ export default function DockingHud({ roverId }) {
/* Mobile already presents its own touch-oriented driving controls. The docked
action therefore keeps its plain-language instruction without advertising a
keyboard shortcut that is irrelevant on that layout. */
const driveKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.driveMacro?.[0])
: '';
const dockKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.dockMacro?.[0])
: '';
const driveKeyLabel = layout === 'desktop' ? <ControlHint actionId="driveMacro" /> : '';
const dockKeyLabel = layout === 'desktop' ? <ControlHint actionId="dockMacro" /> : '';
const batteryPodOpen = podSettings?.battery !== false;
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
+3 -8
View File
@@ -16,8 +16,8 @@ import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useControlActions } from '../../controls/index.js';
import ControlHint from '../ControlHint/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
@@ -283,12 +283,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
);
}
function keyLabelFor(keymap, actionId) {
return formatKeyLabel(keymap?.[actionId]?.[0]);
}
function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap);
const rows = [
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
@@ -305,7 +300,7 @@ function PtzControlReference() {
{rows.map(([label, actionId]) => (
<div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span>
<KeyPill label={keyLabelFor(keymap, actionId)} />
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
))}
</CardFrame>
@@ -1,34 +1,32 @@
import { useMemo } from 'react';
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButton from '../SocialButton/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { getSocialById } from '../../lib/socials.js';
function ControlRow({ label, keyLabel }) {
function ControlRow({ label, actionId }) {
return (
<div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200">
<span>{label}</span>
<KeyPill label={keyLabel} />
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
);
}
function DesktopQuickstart({ keymap }) {
function DesktopQuickstart() {
return (
<div className="space-y-0.5">
<p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p>
<div className="space-y-0.5">
<p className="text-sm text-slate-200">2. Drive with these keybindings:</p>
<div className="space-y-0.5">
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
<ControlRow label="Turn Left" keyLabel={formatKeyLabel(keymap?.driveLeft?.[0])} />
<ControlRow label="Turn Right" keyLabel={formatKeyLabel(keymap?.driveRight?.[0])} />
<ControlRow label="Move faster" keyLabel={formatKeyLabel(keymap?.boostModifier?.[0])} />
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
<ControlRow label="Forward" actionId="driveForward" />
<ControlRow label="Backward" actionId="driveBackward" />
<ControlRow label="Turn Left" actionId="driveLeft" />
<ControlRow label="Turn Right" actionId="driveRight" />
<ControlRow label="Move faster" actionId="boostModifier" />
<ControlRow label="Move slower" actionId="slowModifier" />
</div>
</div>
<p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p>
@@ -75,9 +73,7 @@ export default function QuickstartOverlay({
onToggleShowOnLoad,
onClose,
}) {
const rawKeymap = useControlSelector((control) => control.state.keymap);
const isDesktop = layout === 'desktop';
const keymap = useMemo(() => rawKeymap || {}, [rawKeymap]);
if (!visible) return null;
@@ -97,7 +93,7 @@ export default function QuickstartOverlay({
</div>
<div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}>
<section className="space-y-0.5 border-b border-slate-700">
{isDesktop ? <DesktopQuickstart keymap={keymap} /> : <MobileQuickstart />}
{isDesktop ? <DesktopQuickstart /> : <MobileQuickstart />}
</section>
{/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
<section className="space-y-0.5">
+2 -3
View File
@@ -18,7 +18,7 @@ import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { useSocket } from '../../context/SocketContext.jsx';
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import ControlHint from '../ControlHint/index.jsx';
import {
DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS,
@@ -129,7 +129,6 @@ function reconnectSocketWithTransport(socket, transport) {
}
export default function SettingsPanel() {
const keymap = useControlSelector((control) => control.state.keymap);
const roverId = useControlSelector((control) => control.state.roverId);
const { sendOiCommand, setSensorStream } = useControlActions();
const canControl = Boolean(roverId);
@@ -170,7 +169,7 @@ export default function SettingsPanel() {
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
const videoFilterCycleKeyLabel = <ControlHint actionId="videoFilterCycle" />;
useEffect(() => {
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
@@ -4,7 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fieldClass } from '../constants.js';
import { useControlSelector } from '../../../controls/index.js';
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
import ControlHint from '../../ControlHint/index.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
import { bytesToBase64, buildAuthHeader } from './base64.js';
@@ -28,7 +28,6 @@ export default function VipAudioUploadCard({
readyMicWhip,
stopMicWhip,
}) {
const keymap = useControlSelector((control) => control.state.keymap);
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
openMicEnabled: false,
@@ -81,7 +80,7 @@ export default function VipAudioUploadCard({
const whipLinkActive = !clipMode && (micState === 'live' || micState === 'starting');
const clipRecording = clipMode && clipState === 'recording';
const clipSending = clipMode && clipState === 'sending';
const pttKeyLabel = formatKeyLabel(keymap?.micPtt?.[0]) || 'M';
const pttKeyLabel = <ControlHint actionId="micPtt" />;
const setPttMode = useCallback(
(nextMode) => {
@@ -12,8 +12,8 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useControlActions } from '../../controls/index.js';
import ControlHint from '../ControlHint/index.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -307,12 +307,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
);
}
function keyLabelFor(keymap, actionId) {
return formatKeyLabel(keymap?.[actionId]?.[0]);
}
function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap);
const rows = [
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
@@ -331,7 +326,7 @@ function PtzControlReference() {
<span className="text-slate-400">{label}</span>
{/* Use the same key display component as the rest of the UI so PTZ
controls read as normal mapped controls instead of custom labels. */}
<KeyPill label={keyLabelFor(keymap, actionId)} />
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
))}
</CardFrame>
+328 -69
View File
@@ -3,21 +3,38 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useControlActions, useControlSelector } from '../ControlContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
import {
GAMEPAD_SETTINGS_DEFAULTS,
GAMEPAD_PROFILE_DEFAULT,
VIDEO_SETTINGS_DEFAULTS,
} from '../../settings/namespaces.js';
import {
advanceCameraAngle,
computeGamepadOutputs,
createProfileForPad,
getPadSignature,
resolveGamepadProfile,
} from './gamepadBindings.js';
import { subscribeGamepadHub } from './gamepadHub.js';
import { isTextEntryActive } from './inputFocusUtils.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import {
isControllerControlLocked,
markControllerDisconnected,
markControllerInputActive,
} from './controllerRuntime.js';
import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { SONG_DEFAULT_DURATION, SONG_DEFAULT_NOTE, SONG_NOTE_RANGE } from '../constants.js';
const SOURCE = 'gamepad';
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
const DRIVE_RATE_MS = 100;
const AUX_RATE_MS = 100;
const CONTROLLER_ACTIVITY_AXIS_MIN = 0.24;
const CONTROLLER_ACTIVITY_AXIS_DELTA = 0.08;
const VIDEO_FILTER_SEQUENCE = ['none', 'grayscale', 'greenscale'];
function areVectorsEqual(a, b) {
return a && b && a.x === b.x && a.y === b.y && a.boost === b.boost;
@@ -37,15 +54,63 @@ function isAuxIdle(aux) {
return !aux.main && !aux.side && !aux.vacuum;
}
function pickActivePad(pads, activeSignature) {
function pickActivePad(pads, activeInstanceKey) {
if (!pads || pads.length === 0) return null;
if (activeSignature) {
const match = pads.find((pad) => pad.signature === activeSignature);
if (activeInstanceKey) {
const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
if (match) return match;
}
return pads[0];
}
function hasMeaningfulControllerChange(pad, previous) {
if (!previous) {
return pad.buttons.some((button) => button.pressed) ||
pad.axes.some((axis) => Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN);
}
const buttonPressed = pad.buttons.some(
(button, index) => button.pressed && !previous.buttons?.[index]?.pressed,
);
if (buttonPressed) return true;
return pad.axes.some((axis, index) => {
const oldAxis = previous.axes?.[index] ?? 0;
return Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN &&
Math.abs(axis - oldAxis) >= CONTROLLER_ACTIVITY_AXIS_DELTA;
});
}
function isControllerNeutral(pad) {
return !pad.buttons.some((button) => button.pressed || button.value > 0.1) &&
!pad.axes.some((axis) => Math.abs(axis) > 0.2);
}
function nextVideoFilter(value) {
const index = VIDEO_FILTER_SEQUENCE.indexOf(value);
return VIDEO_FILTER_SEQUENCE[(index < 0 ? 0 : index + 1) % VIDEO_FILTER_SEQUENCE.length];
}
function cycleHomeAssistant(latest, targetState) {
const homeAssistant = latest.homeAssistant;
if (!homeAssistant?.enabled || !homeAssistant?.connected) return;
if (
(homeAssistant.lightPolicy?.locked || homeAssistant.lightPolicy?.lockedOn) &&
!latest.adminCanControlLockedLights
) {
return;
}
const entities = (homeAssistant.entities ?? []).filter(
(entity) =>
(entity.type === 'light' || entity.type === 'switch') &&
entity.available !== false &&
entity.state !== 'unavailable',
);
const ordered = targetState === 'on' ? entities : [...entities].reverse();
const next = ordered.find((entity) =>
targetState === 'on' ? entity.state !== 'on' : entity.state === 'on',
);
if (next) latest.homeAssistantSetState(next.id, targetState).catch(() => {});
}
export default function GamepadInputManager() {
const {
setMode,
@@ -57,11 +122,27 @@ export default function GamepadInputManager() {
toggleHeadlight,
toggleLaser,
registerInputState,
sendSong,
setSongNote,
startHorn,
stopHorn,
setMicPttActive,
} = useControlActions();
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
const roverId = useControlSelector((control) => control.state.roverId);
const dockAssist = useManualDockAssist();
const { focusChat } = useChatActions();
const { isChatFocused } = useChatFocus();
const { homeAssistantSetState, pushAlert } = useSessionActions();
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const role = useSessionSelector((state) => state.session?.role || null);
const sessionMode = useSessionSelector((state) => state.session?.mode || null);
const songNote = useControlSelector((control) => control.state.song?.note);
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace(
'video',
VIDEO_SETTINGS_DEFAULTS,
);
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
'gamepad',
GAMEPAD_SETTINGS_DEFAULTS,
@@ -75,6 +156,12 @@ export default function GamepadInputManager() {
const lastAuxSentAtRef = useRef(0);
const lastServoAtRef = useRef(0);
const lastServoAngleRef = useRef(null);
const previousPadRef = useRef(null);
const lastConnectedSignatureRef = useRef(null);
const lastConnectedInstanceKeyRef = useRef(null);
const lastRegisteredSignatureRef = useRef(null);
const controllerLockedRef = useRef(false);
const waitingForNeutralRef = useRef(false);
// The hub subscription is intentionally stable, so this ref is the bridge back to the latest
// React values. Rewriting it after each commit is cheaper than tearing down browser gamepad
// listeners every time settings, camera state, or control callbacks change.
@@ -91,7 +178,10 @@ export default function GamepadInputManager() {
latest.saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
if (current.profiles?.[signature]) return current;
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
const base = resolveGamepadProfile(
current?.defaults?.profile,
GAMEPAD_PROFILE_DEFAULT,
);
const nextProfile = createProfileForPad(padState, base);
return {
...current,
@@ -114,12 +204,19 @@ export default function GamepadInputManager() {
const config = latest?.cameraConfig;
if (!latest || !config) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const cameraMode = calibration?.cameraMode ?? 'absolute';
const sensitivity = Math.max(1, Math.min(180, calibration?.cameraSensitivity ?? 60));
const cameraMode = calibration?.cameraMode ?? 'velocity';
const sensitivity = calibration?.cameraSensitivity ?? 60;
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
if (cameraMode === 'velocity') {
if (Math.abs(axisValue) <= 0.001) {
/* Neutral is the safe synchronization point: no controller motion is being integrated, so
an angle changed by another UI can replace our accumulator without causing jitter. */
if (typeof latest.cameraAngle === 'number') lastServoAngleRef.current = latest.cameraAngle;
lastServoAtRef.current = now;
return;
}
const dt = Math.min(50, now - lastServoAtRef.current || 16);
const delta = axisValue * sensitivity * (dt / 1000);
if (Math.abs(delta) < 0.01) return;
const baseline =
typeof lastServoAngleRef.current === 'number'
? lastServoAngleRef.current
@@ -128,14 +225,12 @@ export default function GamepadInputManager() {
: typeof config.homeAngle === 'number'
? config.homeAngle
: 0;
const nextAngle = baseline + delta;
const nextAngle = advanceCameraAngle(baseline, axisValue, sensitivity, dt, { min, max });
latest.setServoAngle(nextAngle);
lastServoAngleRef.current = nextAngle;
lastServoAtRef.current = now;
return;
}
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
const home = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2;
const angle =
axisValue < 0
@@ -153,9 +248,29 @@ export default function GamepadInputManager() {
latest.setServoAngle(angle);
}, []);
const activeSignature = useMemo(
() => gamepadSettings?.activeSignature ?? null,
[gamepadSettings?.activeSignature],
const neutralizeController = useCallback((latest) => {
/*
Every path that makes controller commands unsafe converges here. In particular, held horn
and microphone actions need releases just as much as drive and motor axes need zeroes.
*/
latest.setCameraAxisIntent(0);
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
lastVectorRef.current = ZERO_VECTOR;
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
}
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
lastAuxRef.current = ZERO_AUX;
latest.setAuxMotors(ZERO_AUX);
}
if (buttonStateRef.current.get('hornHonk')) latest.stopHorn();
if (buttonStateRef.current.get('micPtt')) latest.setMicPttActive(false);
buttonStateRef.current = new Map();
reverseStateRef.current = { main: false, side: false };
}, []);
const activeInstanceKey = useMemo(
() => gamepadSettings?.activeInstanceKey ?? null,
[gamepadSettings?.activeInstanceKey],
);
useLayoutEffect(() => {
@@ -163,83 +278,132 @@ export default function GamepadInputManager() {
// after React commits. Updating this ref before paint keeps the stable hub callback aligned
// with the newest settings and control actions without resubscribing to the hub.
latestRef.current = {
activeSignature,
activeInstanceKey,
adminCanControlLockedLights:
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
cameraAngle,
cameraConfig,
dockAssist,
focusChat,
gamepadSettings,
homeAssistant,
homeAssistantSetState,
isChatFocused,
pushAlert,
registerInputState,
roverId,
runMacro,
saveGamepadSettings,
saveVideoSettings,
sendSong,
setAuxMotors,
setCameraAxisIntent,
setDriveVector,
setMicPttActive,
setMode,
setSongNote,
setServoAngle,
songNote,
startHorn,
stopHorn,
toggleHeadlight,
toggleLaser,
videoColorFilter: videoSettings?.colorFilter ?? VIDEO_SETTINGS_DEFAULTS.colorFilter,
};
});
useEffect(() => {
return subscribeGamepadHub((hubState) => {
const unsubscribe = subscribeGamepadHub((hubState) => {
const latest = latestRef.current;
if (!latest) return;
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
const activePad = pickActivePad(hubState.pads, latest.activeInstanceKey);
if (!activePad) {
// A disconnected controller cannot deliver a final neutral axis sample.
// Publish it here so PTZ zoom never depends on the browser doing so.
latest.setCameraAxisIntent(0);
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
lastVectorRef.current = ZERO_VECTOR;
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
}
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
lastAuxRef.current = ZERO_AUX;
latest.setAuxMotors(ZERO_AUX);
}
buttonStateRef.current = new Map();
reverseStateRef.current = { main: false, side: false };
// A disconnect cannot provide release samples, so synthesize every required release once.
neutralizeController(latest);
markControllerDisconnected(lastConnectedSignatureRef.current);
previousPadRef.current = null;
lastConnectedSignatureRef.current = null;
lastConnectedInstanceKeyRef.current = null;
lastRegisteredSignatureRef.current = null;
controllerLockedRef.current = false;
waitingForNeutralRef.current = false;
lastDriveSentAtRef.current = 0;
lastAuxSentAtRef.current = 0;
latest.registerInputState(SOURCE, { connected: false });
return;
}
if (isTextEntryActive()) {
// Entering text blocks gamepad control immediately, including a held
// camera axis that otherwise would keep its last PTZ zoom direction.
latest.setCameraAxisIntent(0);
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
lastVectorRef.current = ZERO_VECTOR;
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
if (
lastConnectedInstanceKeyRef.current &&
lastConnectedInstanceKeyRef.current !== activePad.instanceKey
) {
/* Browser slots distinguish two identical controllers. Neutralize the old owner before
accepting the replacement and require any controls already held on the new pad to be
released, preventing a selection change from inheriting drive, horn, or microphone. */
neutralizeController(latest);
previousPadRef.current = null;
lastRegisteredSignatureRef.current = null;
waitingForNeutralRef.current = true;
}
if (hasMeaningfulControllerChange(activePad, previousPadRef.current)) {
markControllerInputActive(activePad);
}
previousPadRef.current = activePad;
lastConnectedSignatureRef.current = activePad.signature;
lastConnectedInstanceKeyRef.current = activePad.instanceKey;
const controlsBlocked = isTextEntryActive() || isControllerControlLocked();
if (controlsBlocked) {
/* Configuration and text entry still receive hub snapshots, but they must never leak
through to physical rover actions. Only publish/reset on the transition into the lock. */
if (!controllerLockedRef.current) {
neutralizeController(latest);
latest.registerInputState(SOURCE, { connected: true, blocked: true });
}
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
lastAuxRef.current = ZERO_AUX;
latest.setAuxMotors(ZERO_AUX);
}
buttonStateRef.current = new Map();
reverseStateRef.current = { main: false, side: false };
latest.registerInputState(SOURCE, { connected: true, blocked: true });
controllerLockedRef.current = true;
waitingForNeutralRef.current = true;
return;
}
if (controllerLockedRef.current) {
controllerLockedRef.current = false;
latest.registerInputState(SOURCE, { connected: true, blocked: false });
}
/* A control held while a dialog closes must not become a fresh command. Require a neutral
sample before rearming the controller, just like releasing an emergency-stop switch. */
if (waitingForNeutralRef.current) {
if (!isControllerNeutral(activePad)) return;
waitingForNeutralRef.current = false;
}
ensureProfile(activePad);
const signature = activePad.signature;
const profile =
const storedProfile =
latest.gamepadSettings?.profiles?.[signature] ??
latest.gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT;
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
const outputs = computeGamepadOutputs(activePad, profile);
if (!areVectorsEqual(outputs.driveVector, lastVectorRef.current)) {
const driveVector = {
...outputs.driveVector,
boost: Boolean(outputs.buttons.boostModifier),
};
if (!areVectorsEqual(driveVector, lastVectorRef.current)) {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const idle = vectorMagnitude(outputs.driveVector) < 0.02;
const idle = vectorMagnitude(driveVector) < 0.02;
if (idle || now - lastDriveSentAtRef.current >= DRIVE_RATE_MS) {
lastVectorRef.current = outputs.driveVector;
lastVectorRef.current = driveVector;
lastDriveSentAtRef.current = now;
latest.setDriveVector(outputs.driveVector, { source: SOURCE });
const precisionSpeed = profile.calibration?.precisionSpeed ?? 100;
const baseSpeed = profile.calibration?.baseSpeed ?? 500;
const turboSpeed = profile.calibration?.turboSpeed ?? 500;
latest.setDriveVector(driveVector, {
source: SOURCE,
speedOptions: outputs.buttons.slowModifier
? { baseSpeed: precisionSpeed, boostSpeed: precisionSpeed }
: { baseSpeed, boostSpeed: turboSpeed },
});
}
}
@@ -250,12 +414,30 @@ export default function GamepadInputManager() {
const side = reverseStateRef.current.side
? -Math.round(sideMagnitude * auxSideScale)
: Math.round(sideMagnitude * auxSideScale);
/* Digital bindings intentionally override proportional axes. This mirrors keyboard aux
precedence exactly while preserving the controller-friendly analog defaults. */
let aux = {
main: outputs.auxAxis.main !== 0 ? main : 0,
side: outputs.auxAxis.side !== 0 ? side : 0,
vacuum: outputs.buttons.vacuum ? 127 : 0,
main: outputs.buttons.auxMainForward
? 127
: outputs.buttons.auxMainReverse
? -127
: outputs.auxAxis.main !== 0
? main
: 0,
side: outputs.buttons.auxSideForward
? 127
: outputs.buttons.auxSideReverse
? -70
: outputs.auxAxis.side !== 0
? side
: 0,
vacuum: (outputs.buttons.vacuum || outputs.buttons.auxVacuumFast)
? 127
: outputs.buttons.auxVacuumSlow
? 50
: 0,
};
if (outputs.buttons.allAux) {
if (outputs.buttons.allAux || outputs.buttons.auxAllForward) {
aux = { main: 127, side: 127, vacuum: 127 };
}
if (!areAuxEqual(aux, lastAuxRef.current)) {
@@ -305,6 +487,67 @@ export default function GamepadInputManager() {
handleButtonEdge('laserToggle', false);
}
const hornWasPressed = buttonStateRef.current.get('hornHonk') || false;
if (outputs.buttons.hornHonk && handleButtonEdge('hornHonk', true)) {
latest.startHorn();
} else if (!outputs.buttons.hornHonk) {
handleButtonEdge('hornHonk', false);
if (hornWasPressed) latest.stopHorn();
}
const micWasPressed = buttonStateRef.current.get('micPtt') || false;
if (outputs.buttons.micPtt && handleButtonEdge('micPtt', true)) {
latest.setMicPttActive(true);
} else if (!outputs.buttons.micPtt) {
handleButtonEdge('micPtt', false);
if (micWasPressed) latest.setMicPttActive(false);
}
if (outputs.buttons.videoFilterCycle && handleButtonEdge('videoFilterCycle', true)) {
const nextFilter = nextVideoFilter(latest.videoColorFilter);
latest.saveVideoSettings((current) => ({ ...(current ?? {}), colorFilter: nextFilter }));
latest.pushAlert({
id: 'video-filter-active',
title: 'Video filter',
message: `Rover video filter: ${nextFilter}`,
color: '#38bdf8',
lifetimeMs: 1600,
});
} else if (!outputs.buttons.videoFilterCycle) {
handleButtonEdge('videoFilterCycle', false);
}
if (outputs.buttons.chatFocus && handleButtonEdge('chatFocus', true)) {
if (!latest.isChatFocused) latest.focusChat();
} else if (!outputs.buttons.chatFocus) {
handleButtonEdge('chatFocus', false);
}
/* Song directions share identical edge and wrap behavior; the table keeps the two actions
symmetric and prevents one direction from silently diverging during later changes. */
for (const [actionId, direction] of [['songNoteUp', 1], ['songNoteDown', -1]]) {
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
const [minNote, maxNote] = SONG_NOTE_RANGE;
const currentNote = typeof latest.songNote === 'number' ? latest.songNote : SONG_DEFAULT_NOTE;
const candidate = currentNote + direction;
const nextNote = candidate > maxNote ? minNote : candidate < minNote ? maxNote : candidate;
latest.setSongNote(nextNote);
latest.sendSong([{ note: nextNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
} else if (!outputs.buttons[actionId]) {
handleButtonEdge(actionId, false);
}
}
/* Room-control cycling differs only by target state, so both bindings use the same policy
checks and ordered entity selection. */
for (const [actionId, targetState] of [['homeAssistantOn', 'on'], ['homeAssistantOff', 'off']]) {
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
cycleHomeAssistant(latest, targetState);
} else if (!outputs.buttons[actionId]) {
handleButtonEdge(actionId, false);
}
}
/*
PTZ zoom consumes the live signed gamepad axis, including its zero
position, so releasing the stick is an explicit stop instead of merely
@@ -312,24 +555,40 @@ 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) {
handleCameraAxis(outputs.cameraAxis, profile.calibration);
/* Tank mode's camera input is a pair of direction buttons rather than a position-bearing
analog axis. Always interpret those buttons as velocity commands; absolute mode would
incorrectly jump directly to a servo endpoint on every D-pad press. The saved analog
camera preference remains untouched and resumes when single-stick steering is selected. */
const cameraCalibration = profile.calibration?.driveMode === 'tank'
? { ...profile.calibration, cameraMode: 'velocity' }
: profile.calibration;
if (
!handledAsPtzZoom &&
(cameraCalibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
) {
handleCameraAxis(outputs.cameraAxis, cameraCalibration);
}
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,135 @@
// 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 TANK_DIRECTION_GLYPHS = {
driveForward: ['up', 'up'],
driveBackward: ['down', 'down'],
driveLeft: ['down', 'up'],
driveRight: ['up', 'down'],
};
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 };
if (profile?.calibration?.driveMode === 'tank' && actionId === 'cameraUp') {
return { binding: profile?.bindings?.tankCameraUp ?? null, direction: null };
}
if (profile?.calibration?.driveMode === 'tank' && actionId === 'cameraDown') {
return { binding: profile?.bindings?.tankCameraDown ?? null, 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) {
if (profile?.calibration?.driveMode === 'tank' && TANK_DIRECTION_GLYPHS[actionId]) {
const leftSource = profile?.bindings?.tankLeft?.sources?.[0];
const rightSource = profile?.bindings?.tankRight?.sources?.[0];
if (!leftSource || !rightSource) return '—';
const type = controllerType(controller, profile?.promptStyle);
const [leftDirection, rightDirection] = TANK_DIRECTION_GLYPHS[actionId];
/* A tank movement is inherently a two-input gesture. Showing both compact stick directions
makes help labels accurate without spelling out controller model names or raw axis numbers. */
return `${compactSourceName(leftSource, type)} ${DIRECTION_GLYPHS[leftDirection]} + ${compactSourceName(rightSource, type)} ${DIRECTION_GLYPHS[rightDirection]}`;
}
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,69 @@
// 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 { describeController, formatControllerBinding } from './controllerLabels.js';
test('uses the manually selected PlayStation button family', () => {
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation' };
const label = formatControllerBinding(profile, 'vacuum', {
id: 'Controller hidden by browser privacy mode',
mapping: 'standard',
});
assert.equal(label, '○');
});
test('recognizes the exact Linux DualSense browser identifier', () => {
const controller = {
id: '054c-0ce6-Sony Interactive Entertainment DualSense Wireless Controller',
mapping: 'standard',
};
assert.equal(describeController(controller).description, 'Sony DualSense (PS5)');
assert.equal(formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'vacuum', controller), '○');
assert.equal(formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'allAux', controller), '×');
});
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, 'LS ↑');
});
test('tank steering prompts show both track directions compactly', () => {
const profile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
},
};
const controller = { id: 'Xbox Wireless Controller', mapping: 'standard' };
assert.equal(formatControllerBinding(profile, 'driveForward', controller), 'LS ↑ + RS ↑');
assert.equal(formatControllerBinding(profile, 'driveLeft', controller), 'LS ↓ + RS ↑');
assert.equal(formatControllerBinding(profile, 'cameraUp', controller), 'D↑');
assert.equal(formatControllerBinding(profile, 'cameraDown', controller), 'D↓');
});
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→');
});
@@ -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,
);
}
+193 -42
View File
@@ -1,6 +1,42 @@
// Gamepad Bindings
// 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;
const ABSOLUTE_CAMERA_DEADZONE = 0.01;
/*
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 = [
'tankCameraUp',
'tankCameraDown',
'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';
@@ -26,6 +62,57 @@ 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,
/* Old detector-specific prompt values are invalid for the replacement library. Returning to
automatic detection ensures a previously selected workaround cannot mask the real device. */
promptStyle: requiresBehaviorUpgrade ? base.promptStyle : current.promptStyle ?? base.promptStyle,
calibration: {
...(base.calibration ?? {}),
...(current.calibration ?? {}),
/* Profile upgrades retain personal response tuning except for defaults whose old values
caused broken camera behavior or imposed an unintended drive-speed ceiling. */
...(requiresBehaviorUpgrade
? {
cameraMode: base.calibration?.cameraMode,
baseSpeed: base.calibration?.baseSpeed,
}
: {}),
},
bindings: {
/* Version four intentionally replaces the old arbitrary default layout as one coherent
migration. Bindings are controller-local preferences, and the project does not retain
backwards compatibility with obsolete layouts; calibration and hardware metadata remain. */
...(requiresBehaviorUpgrade
? cloneProfile(base.bindings ?? {})
: { ...(base.bindings ?? {}), ...(current.bindings ?? {}) }),
},
};
}
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,47 +191,103 @@ 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) {
const bindings = profile?.bindings ?? {};
const calibration = profile?.calibration ?? {};
const driveBinding = bindings.drive ?? {};
const driveSource = resolveAxisPairSource(padState, driveBinding.sources);
let driveX = clampUnit(driveSource.x);
let driveY = clampUnit(driveSource.y);
const driveDeadzone = Math.min(Math.max(calibration.driveDeadzone ?? 0.18, 0), 0.8);
const driveCurved = applyRadialDeadzone(driveX, driveY, driveDeadzone);
driveX = applyCurve(driveCurved.x, calibration.driveCurve);
driveY = applyCurve(driveCurved.y, calibration.driveCurve);
const driveMode = calibration.driveMode === 'tank' ? 'tank' : 'single';
let driveX = 0;
let driveY = 0;
let driveSources;
let tankTracks = null;
if (driveMode === 'tank') {
const leftSource = resolveAxisSource(padState, bindings.tankLeft?.sources);
const rightSource = resolveAxisSource(padState, bindings.tankRight?.sources);
/* Each track gets its own axial deadzone and response curve before mixing. Applying a radial
deadzone to two independent throttles would make one track's drift or movement change the
activation threshold of the other, which is especially unpleasant during slow pivots. */
const leftTrack = applyCurve(
applyAxisDeadzone(clampUnit(leftSource.value), driveDeadzone),
calibration.driveCurve,
);
const rightTrack = applyCurve(
applyAxisDeadzone(clampUnit(rightSource.value), driveDeadzone),
calibration.driveCurve,
);
/* The shared drive mixer later computes left = forward + turn and right = forward - turn.
This inverse transform therefore preserves the requested track values exactly while keeping
tank-controller knowledge out of ControlContext and the rover command transport. */
driveX = clampUnit((leftTrack - rightTrack) / 2);
driveY = clampUnit((leftTrack + rightTrack) / 2);
tankTracks = { left: leftTrack, right: rightTrack };
driveSources = { tankLeft: leftSource.source, tankRight: rightSource.source };
} else {
const driveBinding = bindings.drive ?? {};
const driveSource = resolveAxisPairSource(padState, driveBinding.sources);
const driveCurved = applyRadialDeadzone(
clampUnit(driveSource.x),
clampUnit(driveSource.y),
driveDeadzone,
);
driveX = applyCurve(driveCurved.x, calibration.driveCurve);
driveY = applyCurve(driveCurved.y, calibration.driveCurve);
driveSources = { drive: driveSource.source };
}
const cameraBinding = bindings.cameraTilt ?? {};
const cameraSource = resolveAxisSource(padState, cameraBinding.sources);
const cameraDeadzone = Math.min(Math.max(calibration.cameraDeadzone ?? 0.08, 0), 0.8);
const cameraSource = driveMode === 'tank'
? { value: 0, source: null }
: resolveAxisSource(padState, cameraBinding.sources);
/* Absolute mode maps the stick directly across the servo's physical range. Its center needs
only a tiny noise guard; applying the velocity deadzone there creates a visibly unresponsive
band around the home angle and makes small position corrections feel delayed. */
const configuredCameraDeadzone = Math.min(
Math.max(calibration.cameraDeadzone ?? 0.08, 0),
0.8,
);
const cameraDeadzone = calibration.cameraMode === 'absolute'
? ABSOLUTE_CAMERA_DEADZONE
: configuredCameraDeadzone;
let cameraAxis = applyAxisDeadzone(clampUnit(cameraSource.value), cameraDeadzone);
cameraAxis = applyCurve(cameraAxis, calibration.cameraCurve);
const auxDeadzone = Math.min(Math.max(calibration.auxDeadzone ?? 0.05, 0), 0.6);
const mainBinding = bindings.mainBrush ?? {};
@@ -157,42 +300,50 @@ 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) => {
/* D-pad vertical has two deliberate owners, one per steering mode. Suppressing the inactive
owner here lets both recommended layouts coexist in one controller profile without a
camera press also playing a song note after switching to tank steering. */
const inactiveForMode =
(driveMode === 'tank' && (actionId === 'songNoteUp' || actionId === 'songNoteDown')) ||
(driveMode === 'single' && (actionId === 'tankCameraUp' || actionId === 'tankCameraDown'));
return [
actionId,
inactiveForMode
? { pressed: false, source: null }
: resolveButtonSource(padState, bindings[actionId]?.sources),
];
}),
);
if (driveMode === 'tank') {
/* Direction buttons form the signed equivalent of the single analog camera axis. Opposing
presses cancel to zero, providing an immediate and deterministic stop for velocity mode. */
cameraAxis = Number(buttonOutputs.tankCameraUp.pressed) -
Number(buttonOutputs.tankCameraDown.pressed);
}
cameraAxis = applyCurve(cameraAxis, calibration.cameraCurve);
return {
driveVector: { x: driveX, y: driveY, boost: false },
// Track values are diagnostic-only; the runtime continues consuming driveVector exclusively.
tankTracks,
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,
...driveSources,
cameraTilt: driveMode === 'tank'
? buttonOutputs.tankCameraUp.source ?? buttonOutputs.tankCameraDown.source
: 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,208 @@
// 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 {
advanceCameraAngle,
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('tank steering preserves independent left and right wheel requests', () => {
const tankProfile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
},
};
const forward = computeGamepadOutputs(pad({ axes: [0, -1, 0, -1] }), tankProfile);
assert.deepEqual(forward.tankTracks, { left: 1, right: 1 });
assert.deepEqual(forward.driveVector, { x: 0, y: 1, boost: false });
const pivotRight = computeGamepadOutputs(pad({ axes: [0, -1, 0, 1] }), tankProfile);
assert.deepEqual(pivotRight.tankTracks, { left: 1, right: -1 });
assert.deepEqual(pivotRight.driveVector, { x: 1, y: 0, boost: false });
const leftOnly = computeGamepadOutputs(pad({ axes: [0, -1, 0, 0] }), tankProfile);
assert.deepEqual(leftOnly.driveVector, { x: 0.5, y: 0.5, boost: false });
});
test('tank steering applies deadzone and remapping to each track independently', () => {
const tankProfile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
driveDeadzone: 0.2,
},
bindings: {
...GAMEPAD_PROFILE_DEFAULT.bindings,
tankLeft: { kind: 'axis', sources: [{ kind: 'axis', index: 0, invert: false }] },
tankRight: { kind: 'axis', sources: [{ kind: 'axis', index: 2, invert: true }] },
},
};
/* The left track is inside its own deadzone while the remapped right track reaches full output;
movement on one side must not pull the other side through a shared radial threshold. */
const output = computeGamepadOutputs(pad({ axes: [0.1, 0, -1, 0] }), tankProfile);
assert.deepEqual(output.tankTracks, { left: 0, right: 1 });
assert.deepEqual(output.driveVector, { x: -0.5, y: 0.5, boost: false });
});
test('tank camera buttons form one signed camera axis without playing song notes', () => {
const tankProfile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
},
};
const up = computeGamepadOutputs(pad({ pressed: [12] }), tankProfile);
assert.equal(up.cameraAxis, 1);
assert.equal(up.buttons.tankCameraUp, true);
assert.equal(up.buttons.songNoteUp, false);
const down = computeGamepadOutputs(pad({ pressed: [13] }), tankProfile);
assert.equal(down.cameraAxis, -1);
assert.equal(down.buttons.tankCameraDown, true);
assert.equal(down.buttons.songNoteDown, false);
const cancelled = computeGamepadOutputs(pad({ pressed: [12, 13] }), tankProfile);
assert.equal(cancelled.cameraAxis, 0);
});
test('single-stick mode keeps analog camera and song buttons separate', () => {
const output = computeGamepadOutputs(
pad({ axes: [0, 0, 0, -1], pressed: [12] }),
GAMEPAD_PROFILE_DEFAULT,
);
assert.equal(output.cameraAxis, 1);
assert.equal(output.buttons.songNoteUp, true);
assert.equal(output.buttons.tankCameraUp, false);
});
test('recommended standard-layout buttons resolve to the intended rover actions', () => {
const output = computeGamepadOutputs(
pad({ pressed: [0, 2, 4, 5, 9, 10, 15] }),
GAMEPAD_PROFILE_DEFAULT,
);
assert.equal(output.buttons.allAux, true);
assert.equal(output.buttons.vacuum, false);
assert.equal(output.buttons.hornHonk, true);
assert.equal(output.buttons.headlightToggle, true);
assert.equal(output.buttons.laserToggle, true);
assert.equal(output.buttons.driveMacro, true);
assert.equal(output.buttons.slowModifier, true);
assert.equal(output.buttons.homeAssistantOn, true);
assert.equal(output.buttons.mainReverse, false);
assert.equal(output.buttons.sideReverse, false);
assert.equal(output.buttons.boostModifier, false);
});
test('button chords require every constituent input', () => {
const profile = resolveGamepadProfile({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
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({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
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({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
bindings: { drive: customDrive },
}, GAMEPAD_PROFILE_DEFAULT);
assert.deepEqual(resolved.bindings.drive, customDrive);
assert.ok(resolved.bindings.hornHonk);
});
test('profile upgrade discards detector-specific prompt values', () => {
const resolved = resolveGamepadProfile({
behaviorVersion: 2,
promptStyle: 'playstation-dual-sense',
}, GAMEPAD_PROFILE_DEFAULT);
assert.equal(resolved.behaviorVersion, GAMEPAD_PROFILE_DEFAULT.behaviorVersion);
assert.equal(resolved.promptStyle, 'auto');
assert.deepEqual(resolved.bindings.allAux, GAMEPAD_PROFILE_DEFAULT.bindings.allAux);
assert.deepEqual(resolved.bindings.headlightToggle, GAMEPAD_PROFILE_DEFAULT.bindings.headlightToggle);
});
test('absolute camera mode always uses its fixed 0.01 deadzone', () => {
const profile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
cameraMode: 'absolute',
cameraDeadzone: 0.4,
},
};
const inside = computeGamepadOutputs(pad({ axes: [0, 0, 0, -0.005] }), profile);
const outside = computeGamepadOutputs(pad({ axes: [0, 0, 0, -0.02] }), profile);
assert.equal(inside.cameraAxis, 0);
assert.ok(outside.cameraAxis > 0.01);
});
test('velocity camera accumulation clamps at the servo limit and reverses immediately', () => {
const atUpperLimit = advanceCameraAngle(45, 1, 180, 50, { min: -45, max: 45 });
const reversing = advanceCameraAngle(atUpperLimit, -1, 180, 50, { min: -45, max: 45 });
assert.equal(atUpperLimit, 45);
assert.equal(reversing, 36);
});
+19 -5
View File
@@ -5,9 +5,10 @@ import { getPadSignature } from './gamepadBindings.js';
const listeners = new Set();
let rafId = null;
let lastState = { pads: [], timestamp: 0 };
let lastState = { pads: [], timestamp: 0, supported: true, error: null };
let hasDeviceListeners = false;
let deviceChangeHandler = null;
let lastReadError = null;
function hasConnectedPads() {
return readGamepads().some((pad) => pad?.connected !== false);
@@ -15,14 +16,24 @@ function hasConnectedPads() {
function readGamepads() {
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
lastReadError = new Error('This browser does not support the Gamepad API.');
return [];
}
try {
const pads = navigator.getGamepads();
lastReadError = null;
if (!pads) return [];
return Array.from(pads).filter(Boolean);
} catch (error) {
/* Permissions Policy can make getGamepads throw instead of returning an empty list. Preserve
that distinction so the setup UI can explain why reconnecting hardware will not help. */
lastReadError = error instanceof Error ? error : new Error(String(error));
return [];
}
const pads = navigator.getGamepads();
if (!pads) return [];
return Array.from(pads).filter(Boolean);
}
function buildPadState(pad) {
const signature = getPadSignature(pad);
return {
index: pad.index,
id: pad.id,
@@ -34,7 +45,8 @@ function buildPadState(pad) {
pressed: Boolean(btn?.pressed),
value: typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0,
})),
signature: getPadSignature(pad),
signature,
instanceKey: `${signature}::slot-${pad.index}`,
};
}
@@ -43,6 +55,8 @@ function updateState() {
lastState = {
pads,
timestamp: typeof performance !== 'undefined' ? performance.now() : Date.now(),
supported: typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function',
error: lastReadError?.message ?? null,
};
listeners.forEach((listener) => listener(lastState));
}
@@ -14,7 +14,7 @@ import { useDriveDockState } from '../../../components/DriveDockAction/driveDock
import { useControlActions, useControlSelector } from '../../../controls/index.js';
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
import RawUserPilePanel from '../../../components/RawUserPilePanel/index.jsx';
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
import ControlHint from '../../../components/ControlHint/index.jsx';
import GPIOToggleControl from '../../../components/GPIOToggleControl/index.jsx';
import HornControl from '../../../components/HornControl/index.jsx';
import CameraTiltControl from '../../../components/CameraTiltControl/index.jsx';
@@ -54,7 +54,6 @@ function TopDownMapPanel() {
function DriveDockPanel() {
const roverId = useControlSelector((control) => control.state.roverId);
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
const keymap = useControlSelector((control) => control.state.keymap);
const camera = useControlSelector((control) => control.state.camera);
const horn = useControlSelector((control) => control.state.horn);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
@@ -81,11 +80,11 @@ function DriveDockPanel() {
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
const headlightLabel = formatKeyLabel(keymap?.headlightToggle?.[0]);
const laserLabel = formatKeyLabel(keymap?.laserToggle?.[0]);
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
const headlightLabel = <ControlHint actionId="headlightToggle" />;
const laserLabel = <ControlHint actionId="laserToggle" />;
const hornLabel = <ControlHint actionId="hornHonk" />;
const upLabel = <ControlHint actionId="cameraUp" />;
const downLabel = <ControlHint actionId="cameraDown" />;
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
/*
Precision movement mode also tightens the servo slider step. The command
+95 -20
View File
@@ -11,63 +11,84 @@ export const INPUT_SETTINGS_DEFAULTS = {
};
export const GAMEPAD_PROFILE_DEFAULT = {
behaviorVersion: 4,
label: 'Default',
promptStyle: 'auto',
calibration: {
// Steering mode changes only how controller axes are interpreted. Both modes still emit the
// same normalized drive vector consumed by the shared rover control pipeline.
driveMode: 'single',
driveDeadzone: 0.18,
cameraDeadzone: 0.08,
auxDeadzone: 0.05,
driveCurve: 'linear',
cameraCurve: 'linear',
auxCurve: 'linear',
cameraMode: 'absolute',
cameraMode: 'velocity',
cameraSensitivity: 60,
auxSideScale: 0.55,
baseSpeed: 500,
turboSpeed: 500,
precisionSpeed: 100,
},
bindings: {
drive: {
kind: 'axisPair',
sources: [{ kind: 'axisPair', x: 0, y: 1, invertX: false, invertY: true }],
},
// Tank steering treats the two vertical stick axes as independent wheel throttles. These
// remain separate bindings so controllers with unusual layouts can capture and invert each
// track without affecting the conventional single-stick mapping above.
tankLeft: {
kind: 'axis',
sources: [{ kind: 'axis', index: 1, invert: true }],
},
tankRight: {
kind: 'axis',
sources: [{ kind: 'axis', index: 3, invert: true }],
},
cameraTilt: {
kind: 'axis',
sources: [
{ kind: 'axis', index: 3, invert: true },
{ kind: 'axis', index: 1, invert: true },
],
sources: [{ kind: 'axis', index: 3, invert: true }],
},
// Tank mode consumes both stick Y axes for driving, so its existing camera axis is exposed as
// two independently remappable buttons. Runtime combines them into the same signed camera
// value used by the analog single-stick binding; no camera-specific command path is added.
tankCameraUp: {
kind: 'button',
sources: [{ kind: 'button', index: 12 }],
},
tankCameraDown: {
kind: 'button',
sources: [{ kind: 'button', index: 13 }],
},
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',
sources: [{ kind: 'button', index: 0 }],
sources: [{ kind: 'button', index: 1 }],
},
allAux: {
kind: 'button',
sources: [{ kind: 'button', index: 1 }],
sources: [{ kind: 'button', index: 0 }],
},
mainReverse: {
kind: 'button',
sources: [{ kind: 'button', index: 4 }],
sources: [],
},
sideReverse: {
kind: 'button',
sources: [{ kind: 'button', index: 5 }],
sources: [],
},
driveMacro: {
kind: 'button',
sources: [{ kind: 'button', index: 2 }],
sources: [{ kind: 'button', index: 9 }],
},
dockMacro: {
kind: 'button',
@@ -75,17 +96,71 @@ export const GAMEPAD_PROFILE_DEFAULT = {
},
headlightToggle: {
kind: 'button',
sources: [{ kind: 'button', index: 9 }],
sources: [{ kind: 'button', index: 4 }],
},
laserToggle: {
kind: 'button',
sources: [{ kind: 'button', index: 5 }],
},
boostModifier: {
kind: 'button',
// Full-stick driving already reaches the rover's 500-unit limit, so a default turbo button
// would claim a useful physical control without changing output.
sources: [],
},
slowModifier: {
kind: 'button',
sources: [{ kind: 'button', index: 10 }],
},
hornHonk: {
kind: 'button',
sources: [{ kind: 'button', index: 2 }],
},
micPtt: {
kind: 'button',
sources: [],
},
videoFilterCycle: {
kind: 'button',
sources: [],
},
chatFocus: {
kind: 'button',
sources: [],
},
songNoteUp: {
kind: 'button',
sources: [{ kind: 'button', index: 12 }],
},
songNoteDown: {
kind: 'button',
sources: [{ kind: 'button', index: 13 }],
},
homeAssistantOn: {
kind: 'button',
sources: [{ kind: 'button', index: 15 }],
},
homeAssistantOff: {
kind: 'button',
sources: [{ kind: 'button', index: 14 }],
},
/* 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,