mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881583ee0a | ||
|
|
8ff3c39765 | ||
|
|
9b5c187aac | ||
|
|
0266bd9568 | ||
|
|
6edb6f6dd0 | ||
|
|
ec8eb1c002 | ||
|
|
3c385126ae | ||
|
|
4635e1b40c | ||
|
|
3d2e75572f | ||
|
|
81994f8a56 | ||
|
|
bfdb6555d8 | ||
|
|
17b1404157 | ||
|
|
b199f45eb2 | ||
|
|
0f08fb3f0d | ||
|
|
0f5a33c1de | ||
|
|
8e96c3cdae | ||
|
|
ba5c1c5d25 | ||
|
|
6a914faffb | ||
|
|
3b99590b3b | ||
|
|
5acbf6e0bf | ||
|
|
3ec45de4b4 | ||
|
|
3b7b2ac21e | ||
|
|
02a32e2524 | ||
|
|
eb1fab50e4 | ||
|
|
f85e258c29 | ||
|
|
72b8db8a31 | ||
|
|
fb31ff52bd | ||
|
|
99d2a7689f | ||
|
|
124369dfd7 | ||
|
|
4e24b5437d |
@@ -38,3 +38,6 @@ server/src/services/balanceBoardService/native/balance_board_worker
|
||||
server/data/fleet-reports.sqlite
|
||||
server/data/fleet-reports.sqlite-shm
|
||||
server/data/fleet-reports.sqlite-wal
|
||||
server/data/configuration.sqlite
|
||||
server/data/configuration.sqlite-shm
|
||||
server/data/configuration.sqlite-wal
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,686 @@
|
||||
# Server administration and container migration
|
||||
|
||||
## Status
|
||||
|
||||
This document is the live implementation tracker for the migration.
|
||||
|
||||
- [x] Phase 1, step 1: Establish the single data-directory contract
|
||||
- [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
|
||||
- [ ] Phase 1, steps 6-9: Backup/restore, restart, and internal video proxy
|
||||
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
|
||||
|
||||
The single data-directory implementation and local verification are complete. Real snapshot generation, legacy-directory cleanup, and runtime filesystem tracing remain deployment checks for the actual server; they do not leave the implementation step open.
|
||||
|
||||
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-14: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet.
|
||||
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file path retained is an operator-selected YAML upload on `/setup`. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
|
||||
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
|
||||
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
|
||||
- 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries.
|
||||
- 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.
|
||||
- `/setup` may initialize the database from a YAML file explicitly selected by the operator; no automatic host migration exists.
|
||||
- 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
|
||||
|
||||
Implementation architecture:
|
||||
|
||||
- Each configurable service owns a side-effect-free fragment containing its key, safe default, and strict schema. One short composition list assembles those fragments into the ordered hierarchical document.
|
||||
- The database validates and commits that complete document as one coherent immutable revision.
|
||||
- The admin UI presents one continuous configuration page in the same top-to-bottom order as the former YAML file.
|
||||
- Nested cards make object relationships readable, but do not create separate categories, navigation destinations, persistence boundaries, or registries.
|
||||
- Shared editor infrastructure owns loading, dirty state, validation errors, revision conflicts, secret operations, and restart-required status for the whole document.
|
||||
- The browser receives this same schema from the protected admin endpoint and renders it with a maintained JSON Schema form library.
|
||||
- Standard JSON Schema types drive ordinary fields, nested objects, enums, and arrays. One field-agnostic widget handles every `writeOnly` secret; there are no feature-specific configuration components in React.
|
||||
|
||||
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. Add optional configuration-file upload to setup
|
||||
|
||||
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. This is not a startup loader, installer migration, command-line workflow, or permanent second source of truth.
|
||||
|
||||
The setup upload must:
|
||||
|
||||
- Accept only an explicitly selected YAML file from `/setup`.
|
||||
- Require the one-time setup code before processing it.
|
||||
- Parse the complete 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 current defaults for absent fields.
|
||||
- Ignore fields that do not exist in the current schema, while reporting invalid values supplied for current fields.
|
||||
- Validate the entire result before writing anything.
|
||||
- Refuse to replace an already-configured database.
|
||||
- Write the configuration, administrators, and audit event atomically.
|
||||
- Record the uploaded filename without storing secret values in the audit event.
|
||||
|
||||
The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes.
|
||||
|
||||
## 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 in `data/setup-code.txt` with owner-only permissions. Logs report the file location but never the credential.
|
||||
4. Serve a restricted `/setup` application.
|
||||
5. Require the setup code before creating the first lockdown administrator.
|
||||
6. Offer manual YAML configuration-file upload as an alternative to creating the first administrator from scratch.
|
||||
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 provide these top-level destinations:
|
||||
|
||||
- Overview and service health
|
||||
- Fleet and rover operations
|
||||
- Users, administrators, verification, and permissions
|
||||
- Configuration, presented as one hierarchical page
|
||||
|
||||
Overview may include application logs, persistent audit history, configuration revisions, backup and restore, and system restart or later container-update state. These operational views do not divide the configuration document into categories.
|
||||
|
||||
Existing components and server operations should be moved or reused rather than duplicated. The identity database page and other isolated administrative pages should become destinations within 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 uses one schema-generated typed form rather than a raw YAML or JSON text editor. Repeatable values such as cameras, entities, links, and buttons receive the form library's generic add, remove, and reorder workflow.
|
||||
|
||||
## 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 explicitly selected YAML file can initialize the empty database exactly once.
|
||||
- The setup upload ignores nonexistent fields and reports invalid values supplied for current fields.
|
||||
- Startup and installation do not search for or modify an old `config.yaml`.
|
||||
- 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.
|
||||
|
||||
### Configuration and administration implementation notes
|
||||
|
||||
Implemented on 2026-09-14:
|
||||
|
||||
- Added one ordered, strictly validated hierarchical configuration assembled from side-effect-free definitions owned by the services that consume each value.
|
||||
- Added immutable SQLite configuration revisions, active-revision tracking, administrator accounts, schema migrations, and persistent administrative audit events under the shared data directory.
|
||||
- Added full-document saves with optimistic revision checking. A stale browser cannot overwrite a newer revision, and invalid or unknown fields cannot become active.
|
||||
- Redacted secrets from browser responses and audit data. The one complete save operation preserves stored secrets unless the administrator explicitly replaces or clears them.
|
||||
- Converted every runtime configuration consumer to the synchronous database-backed configuration service and removed the YAML loader, `SERVER_CONFIG`, and the tracked example YAML.
|
||||
- Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer.
|
||||
- Made setup-file import recursively retain only fields present in the current schema. Stale keys from the permissive YAML era are ignored without aliases or historical translations, while invalid values for real current settings still fail validation; stream-only and snapshot-only room-camera entries remain accepted as they were by the runtime.
|
||||
- Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes.
|
||||
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
|
||||
- Replaced every feature-specific configuration form with `@rjsf/core`; the protected admin snapshot supplies the server's assembled schema, and one generic widget handles all schema-declared secrets.
|
||||
- Replaced RJSF's unthemed Bootstrap markup with a generic MultiRover tree renderer. The complete document now follows schema order as indented object, array, item, and key/value rows; array controls remain readable text beside each item, and the route-specific styling lives outside the global stylesheet.
|
||||
- Replaced the editor's custom section borders, header backgrounds, and indentation guides with the application's shared `CardFrame` at every object, array, and array-item layer. Scalar settings remain compact key/value rows, descriptions use the wider value column, and collection actions stay beside their content instead of moving to the far edge.
|
||||
- Disabled RJSF's internal checkbox label and description generically, leaving the shared field row as the single owner of each boolean setting's name, required marker, and description.
|
||||
- Restored the former example YAML's installation-specific values as both schema-owned input examples and the actual initial values for non-secret settings and collection shapes. The only intentionally empty defaults are the three credentials and active driver HTML; their placeholders still explain the expected input without falsely marking credentials as configured or publishing sample content.
|
||||
- Strengthened top-level hierarchy with a 1.5-rem sibling gap while retaining compact spacing within each configuration section.
|
||||
- Extended `CardFrame` with an optional explicit accent while preserving its assigned-rover default, then gave every configuration nesting level its own complete header-and-border accent. Nested CardFrames themselves now carry the YAML-like indentation, scalar contents remain aligned with their owning card, and descriptions use a larger, higher-contrast treatment.
|
||||
- Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect.
|
||||
- Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules.
|
||||
- Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads.
|
||||
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
|
||||
- Added configuration revision history, rollback, audit history, and restart-required reporting. Graceful restart itself remains step 7.
|
||||
|
||||
Local verification completed:
|
||||
|
||||
- All 107 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import with recursive removal of nonexistent fields, and the earlier filesystem coverage.
|
||||
- Focused admin, route, and identity UI lint passed.
|
||||
- All 20 existing focused web UI tests passed.
|
||||
- The production web UI build completed successfully and regenerated the checked-in server assets.
|
||||
- Installer syntax and repository whitespace checks passed.
|
||||
- A local startup smoke test reached listener initialization. MediaMTX then exited because `/usr/local/bin/mediamtx` is intentionally absent on this development machine; actual enabled integrations and media remain deployment checks for the real server.
|
||||
|
||||
# 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.
|
||||
- [x] Add the configuration schema/database and administrator storage.
|
||||
- [x] Add first-run setup and explicit YAML configuration-file upload.
|
||||
- [x] Convert every configuration consumer and remove YAML runtime loading.
|
||||
- [x] Converge optional feature control into service-owned `enabled` switches and derive the public feature map from those definitions.
|
||||
- [x] Build the centralized admin configuration UI.
|
||||
- [x] 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.
|
||||
@@ -1,278 +0,0 @@
|
||||
admins:
|
||||
- username: admin
|
||||
password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass
|
||||
discord_id: "1234567890"
|
||||
lockdown: false
|
||||
- username: lockdown
|
||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
|
||||
timezone: "America/New_York"
|
||||
|
||||
interInstance:
|
||||
enabled: false
|
||||
directoryUrls:
|
||||
- "https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json"
|
||||
pollIntervalMs: 30000
|
||||
requestTimeoutMs: 5000
|
||||
profile:
|
||||
publicUrl: "https://rover.example.com"
|
||||
name: "Example Rover Server"
|
||||
description: "A short public description of this rover server."
|
||||
color: "#38bdf8"
|
||||
|
||||
llmCommentary:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
|
||||
overseerControl:
|
||||
enabled: false
|
||||
# autonomous runs the existing vote-gated loop forever; directAddress only
|
||||
# runs one cycle when a chat message mentions the configured name.
|
||||
mode: "autonomous"
|
||||
observeOnly: true
|
||||
postToolsOnlyMessages: false
|
||||
tiebreakerEnable: false
|
||||
runWhileNoPeopleOnline: false
|
||||
name: "The Overseer"
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
profileImageUrl: "https://example.com/overseer.png"
|
||||
gateIntervalMs: 2000
|
||||
|
||||
barcodeGames:
|
||||
enabled: false
|
||||
botName: "Barcode Games"
|
||||
profileImageUrl: "https://example.com/barcode-games.png"
|
||||
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
# Example: http://media-server.local:8889/video
|
||||
whepBaseUrl: "http://media-server.local:8889/video"
|
||||
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
|
||||
# candidates. Include every public and LAN address browsers use to reach this server.
|
||||
# The server generates MediaMTX's runtime configuration from this list; never edit a
|
||||
# separate mediamtx.yml for a new installation.
|
||||
additionalHosts:
|
||||
- "rover.example.com"
|
||||
- "media-server.local"
|
||||
|
||||
bandwidthSavings:
|
||||
# Duplicate driver-tab handling for the same browser identity.
|
||||
# allowed: no duplicate-tab protection
|
||||
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
||||
# notAllowed: every identity is limited to one driver tab
|
||||
multiTabProtection: "verifiedOnly"
|
||||
# Disconnect rover video when its player is outside the viewport or the web
|
||||
# page is in a background browser tab. Rover audio is a separate stream and
|
||||
# remains connected. /mini intentionally keeps its existing always-warm video
|
||||
# behavior regardless of this option.
|
||||
pauseHiddenRoverVideo: false
|
||||
# Video for users who are attached to a source but do not currently own its
|
||||
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
|
||||
# whenever the normal mode/visibility rules allow it.
|
||||
nonTurnVideo:
|
||||
mode: "snapshots"
|
||||
# Snapshot mode activates only when controllable users exceed this number.
|
||||
# A controllable user is attached to a rover or PTZ as operator/queue, not a
|
||||
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
|
||||
# actually attached to a controllable source.
|
||||
userThreshold: 0
|
||||
# Live video for spectators outside the local network. Local spectators are
|
||||
# not restricted by this switch because LAN traffic is not the upload limit.
|
||||
externalSpectatorVideo: "snapshots"
|
||||
# Whether non-local users may enter the spectator page.
|
||||
# off: block external spectators
|
||||
# on: allow external spectators
|
||||
# verifiedOnly: require a verified identity, but no separate spectator grant
|
||||
# admin: require an identity feature-state grant at spectatorAccess.external
|
||||
externalSpectatorAccess: "on"
|
||||
|
||||
audioForward:
|
||||
enabled: true
|
||||
ffmpegBin: "ffmpeg"
|
||||
streamSuffix: "-fwd"
|
||||
maxUploadBytes: 8388608
|
||||
|
||||
audioLevels:
|
||||
# Base multipliers (0.0 - 4.0) applied before any approved user's signed
|
||||
# personal adjustment. The server clamps every final rover gain to this same
|
||||
# hard multiplier range.
|
||||
hornGain: 1.0
|
||||
ttsGain: 1.0
|
||||
forwardGain: 1.0
|
||||
# Approved users may move each personal slider this far below or above the
|
||||
# base multiplier. Browser cookies store percentages, never raw multipliers.
|
||||
maxPersonalAdjustmentPercent: 50
|
||||
|
||||
homeAssistant:
|
||||
enabled: false
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
neato:
|
||||
enabled: false
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
lift:
|
||||
enabled: false
|
||||
# Two Home Assistant switches controlling lift direction.
|
||||
# Raise sequence: down off -> wait interlockMs -> up on
|
||||
# Lower sequence: up off -> wait interlockMs -> down on
|
||||
upSwitch: "switch.lift_up"
|
||||
downSwitch: "switch.lift_down"
|
||||
interlockMs: 2000
|
||||
commandCooldownMs: 3000
|
||||
entities:
|
||||
- id: "light.lab_main"
|
||||
name: "Lab Lights"
|
||||
- id: "switch.dock_power"
|
||||
name: "Dock Power"
|
||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
||||
# For room-light policy, all configured entities are treated as room lights (including switches).
|
||||
buttons:
|
||||
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Human alert button
|
||||
stateEquals: "on"
|
||||
cooldownMs: 15000
|
||||
action: "humanAlert"
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Mode button: turns
|
||||
stateEquals: "double"
|
||||
cooldownMs: 2000
|
||||
action: "modeTurns"
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Mode button: admin
|
||||
stateEquals: "hold"
|
||||
cooldownMs: 2000
|
||||
action: "modeAdmin"
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Room lights lock toggle
|
||||
stateEquals: "toggle"
|
||||
cooldownMs: 1000
|
||||
action: "lightsLockToggle"
|
||||
|
||||
roomCameras:
|
||||
enabled: false
|
||||
cameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
|
||||
ptzCamera:
|
||||
enabled: false
|
||||
name: "PTZ Camera"
|
||||
host: "192.168.0.8"
|
||||
onvifPort: 8000
|
||||
username: "admin"
|
||||
password: "REPLACE_WITH_CAMERA_PASSWORD"
|
||||
# The Reolink TrackMix autotrack profile was token 003 during commissioning.
|
||||
# Keeping this configurable lets firmware/profile resets be fixed without code
|
||||
# changes while the integration still remains a single-camera feature.
|
||||
profileToken: "003"
|
||||
turnDurationMs: 300000
|
||||
# PTZ replay capture needs a known-good replay encoder on the server. Keep it
|
||||
# off by default so adding live PTZ does not start a broken replay worker loop.
|
||||
replayEnabled: false
|
||||
|
||||
kinect:
|
||||
enabled: false
|
||||
# Capture requests are global across 3d/color so one person cannot spam room
|
||||
# uploads for everyone else. This does not affect the native worker's local
|
||||
# camera cache; it only gates browser-requested broadcasts.
|
||||
captureCooldownMs: 10000
|
||||
|
||||
balanceBoard:
|
||||
# The server installer always prepares Bluetooth and the kernel driver. This
|
||||
# switch only starts the service and shows its small live-weight panel.
|
||||
enabled: false
|
||||
|
||||
buttonBox:
|
||||
enabled: false
|
||||
|
||||
barcodeScanner:
|
||||
enabled: false
|
||||
|
||||
commands:
|
||||
# Commands are a core server capability shared by site chat and optional
|
||||
# transports. Their names therefore do not belong to Discord configuration.
|
||||
prefix: "rs"
|
||||
# Set this to null to disable the legacy bare time-status shortcut.
|
||||
timeStatusCommand: "ts"
|
||||
|
||||
discord:
|
||||
# Discord is optional. A token by itself never enables an external login.
|
||||
enabled: false
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
siteUrl: "https://rover.example.com"
|
||||
channels:
|
||||
general: "123456789012345678"
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
# chat bridge is configured per guild via the shared `commands.prefix`
|
||||
replay: "123456789012345678"
|
||||
humanAlerts: "123456789012345678"
|
||||
roles:
|
||||
stalkerPing: "123456789012345678"
|
||||
announcementPing: "123456789012345678"
|
||||
adminPing: "123456789012345678"
|
||||
humanAlertPing: "123456789012345678"
|
||||
|
||||
socials:
|
||||
enabled: false
|
||||
links:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
|
||||
# Optional trusted HTML card shown at the bottom of the desktop driver page's
|
||||
# left column. Leave html empty (or omit this section) to hide the card. This
|
||||
# content is sent to driver browsers without sanitization, so only place markup
|
||||
# here that is controlled by the server operator.
|
||||
driverAd:
|
||||
title: "Advertisement"
|
||||
html: |
|
||||
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://example.com/ad.png" alt="Advertisement" style="display:block;width:100%;height:auto;">
|
||||
</a>
|
||||
|
||||
# Optional passive fleet telemetry, history, and daily reporting. The collector
|
||||
# observes existing server events and rover sensor frames but never participates
|
||||
# in command, assignment, docking, or safety decisions.
|
||||
fleetReports:
|
||||
enabled: false
|
||||
retention:
|
||||
# Zero retains evidence indefinitely. Set explicit day counts on servers
|
||||
# that prefer bounded storage over complete long-term history.
|
||||
detailedDays: 0
|
||||
minuteSamplesDays: 0
|
||||
battery:
|
||||
enabled: true
|
||||
maximumIntegrationGapSeconds: 5
|
||||
minimumCapacityTestDepthPercent: 60
|
||||
discord:
|
||||
enabled: true
|
||||
sendAt: "08:00"
|
||||
timezone: "America/New_York"
|
||||
privacy:
|
||||
retainChatBodies: true
|
||||
@@ -8,10 +8,17 @@ require('./src/helpers/sensorDecoder');
|
||||
|
||||
require('./src/services/alertService');
|
||||
require('./src/services/authService');
|
||||
// Setup remains available only until the first lockdown administrator exists;
|
||||
// the administrative configuration gateway then owns all subsequent changes.
|
||||
require('./src/services/setupService');
|
||||
require('./src/services/adminConfigurationService');
|
||||
require('./src/services/eventBus');
|
||||
require('./src/services/modeManager');
|
||||
require('./src/services/lockdownGuard');
|
||||
require('./src/services/roverManager');
|
||||
// Help monitoring subscribes to roverManager telemetry before assignment and
|
||||
// session services begin consuming the resulting roster state.
|
||||
require('./src/services/roverHelpService');
|
||||
require('./src/services/commandService');
|
||||
require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
|
||||
+27
-22
@@ -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,9 +28,10 @@ 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"
|
||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
||||
|
||||
@@ -185,12 +184,6 @@ if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
|
||||
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||
fi
|
||||
|
||||
# Bluetoothd remains responsible for discovery and the one-time bond, but its
|
||||
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
|
||||
# 0x13 before the Balance Board worker can listen for the board's front-button
|
||||
@@ -263,11 +256,11 @@ fi
|
||||
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||
|
||||
# Validate the new source of truth before disabling a working legacy service. The validator
|
||||
# performs the same build and YAML serialization as server startup without opening listeners
|
||||
# or leaving a process behind.
|
||||
# Validate database-backed MediaMTX inputs before disabling a working legacy
|
||||
# service. This performs the same build and serialization as startup without
|
||||
# opening listeners or leaving a process behind.
|
||||
runuser -u "$TARGET_USER" -- env \
|
||||
SERVER_CONFIG="$CONFIG_PATH" \
|
||||
SERVER_DATA_DIR="$DATA_DIR" \
|
||||
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
|
||||
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
|
||||
|
||||
@@ -281,10 +274,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
|
||||
@@ -296,9 +302,7 @@ User=$TARGET_USER
|
||||
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
|
||||
@@ -321,8 +325,9 @@ echo
|
||||
echo "Services installed:"
|
||||
echo " multirover.service (Node.js control server with MediaMTX child)"
|
||||
echo
|
||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||
echo "Open /setup to initialize the installation, then use /admin for administration."
|
||||
echo "For fresh setup, read the one-time code from $DATA_DIR/setup-code.txt."
|
||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
||||
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
||||
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
|
||||
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
|
||||
echo "Enable Balance Board support in /admin, press red Sync once, then use the front button for later wakes."
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -5,9 +5,12 @@
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"check:media": "node scripts/checkMedia.js"
|
||||
"check:media": "node scripts/checkMedia.js",
|
||||
"admin:recover": "node scripts/adminAccount.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"discord.js": "^14.25.1",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-card-body{padding:.125rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-CqMkCfQw.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-C2cP8ApY.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
function n(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=t=>n(t,"adminConfig:get"),m=(t,i)=>n(t,"adminConfig:confirmPassword",{password:i}),u=(t,i)=>n(t,"adminConfig:updateConfiguration",i),c=(t,i)=>n(t,"adminConfig:restoreRevision",i),f=(t,i)=>n(t,"adminConfig:createAdministrator",i),g=(t,i)=>n(t,"adminConfig:updateAdministrator",i),C=(t,i)=>n(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>n(t,"setup:status"),l=(t,i)=>n(t,"setup:createAdministrator",i),p=(t,i)=>n(t,"setup:importConfigurationFile",i);export{u as a,m as b,f as c,C as d,A as e,l as f,d as g,p as i,c as r,g as u};
|
||||
//# sourceMappingURL=api-C0PIt_OP.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-C0PIt_OP.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA0B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
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
@@ -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-CNVNbsvk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B5TEaoXl.css">
|
||||
<script type="module" crossorigin src="/assets/index-CqMkCfQw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-1O6avznD.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
// Administrator Recovery Command
|
||||
// Purpose: Creates or resets a lockdown administrator when web authentication cannot be repaired through /admin.
|
||||
// Scope: Performs one explicit local database mutation and never creates a recurring startup bypass.
|
||||
const bcrypt = require('bcrypt');
|
||||
const { getConfigurationDatabase } = require('../src/configuration');
|
||||
|
||||
function usage() {
|
||||
process.stderr.write('Usage: node scripts/adminAccount.js <username> <password> [discord-id]\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [username, password, discordId = ''] = process.argv.slice(2);
|
||||
if (!username || !password) {
|
||||
usage();
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (password.length < 10) throw new Error('Administrator password must be at least 10 characters.');
|
||||
|
||||
const database = getConfigurationDatabase();
|
||||
const existing = database.findAdministratorForAuthentication(username);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
if (existing) {
|
||||
database.updateAdministrator(existing.id, { passwordHash, role: 'lockdown', discordId }, 'command-line-recovery');
|
||||
process.stdout.write(`Reset lockdown administrator ${existing.username}.\n`);
|
||||
return;
|
||||
}
|
||||
const created = database.createAdministrator({ username, passwordHash, discordId, role: 'lockdown' }, 'command-line-recovery');
|
||||
process.stdout.write(`Created lockdown administrator ${created.username}.\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`Administrator recovery failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
|
||||
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
|
||||
const yaml = require('js-yaml');
|
||||
const { loadConfig } = require('../src/helpers/configLoader');
|
||||
const { loadConfig } = require('../src/configuration');
|
||||
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// Configuration System Tests
|
||||
// Purpose: Verifies strict defaults, immutable revisions, secret handling, explicit setup-file import, and administrator safety.
|
||||
// Scope: Uses isolated temporary databases and never opens the development server's data store.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
||||
const { getFeatureFlags } = require('./index');
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter');
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
function createTestDatabase() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-configuration-'));
|
||||
temporaryRoots.push(root);
|
||||
return createConfigurationDatabase({ databasePath: path.join(root, 'configuration.sqlite') });
|
||||
}
|
||||
|
||||
function collectUndocumentedSchemaPaths(schema, pathLabel = '$') {
|
||||
/*
|
||||
The admin editor is entirely schema-generated, so missing schema prose is
|
||||
missing operator documentation. Walk objects, arrays, array item schemas,
|
||||
and scalar leaves instead of checking only named service definitions; this
|
||||
makes every visible level of the hierarchy uphold the same contract.
|
||||
*/
|
||||
if (!schema || typeof schema !== 'object') return [];
|
||||
const missing = typeof schema.description === 'string' && schema.description.trim()
|
||||
? []
|
||||
: [pathLabel];
|
||||
|
||||
if (schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, childSchema]) => {
|
||||
missing.push(...collectUndocumentedSchemaPaths(childSchema, `${pathLabel}.${key}`));
|
||||
});
|
||||
}
|
||||
if (schema.items) {
|
||||
missing.push(...collectUndocumentedSchemaPaths(schema.items, `${pathLabel}[]`));
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function collectSchemaPathsMissingInputExamples(schema, value, pathLabel = '$', insideArray = false) {
|
||||
/*
|
||||
Universal defaults such as timeouts and modes are real saved values. Empty
|
||||
strings and newly-created array items are different: they require an
|
||||
installation-specific value, so the admin form must show an example without
|
||||
persisting a fake hostname, credential, or hardware ID. This walk enforces
|
||||
that distinction across both the current default document and array shapes.
|
||||
*/
|
||||
if (!schema || typeof schema !== 'object') return [];
|
||||
|
||||
if (schema.type === 'array') {
|
||||
return collectSchemaPathsMissingInputExamples(schema.items, undefined, `${pathLabel}[]`, true);
|
||||
}
|
||||
|
||||
if (schema.type === 'object') {
|
||||
return Object.entries(schema.properties || {}).flatMap(([key, childSchema]) => (
|
||||
collectSchemaPathsMissingInputExamples(childSchema, value?.[key], `${pathLabel}.${key}`, insideArray)
|
||||
));
|
||||
}
|
||||
|
||||
// Enumerations and checkboxes already communicate their accepted shape
|
||||
// through their controls, so placeholder examples are only required for
|
||||
// otherwise free-form empty scalar inputs.
|
||||
const needsExample = (value === '' || insideArray)
|
||||
&& !Array.isArray(schema.enum)
|
||||
&& schema.type !== 'boolean';
|
||||
if (!needsExample) return [];
|
||||
return Array.isArray(schema.examples) && schema.examples.length ? [] : [pathLabel];
|
||||
}
|
||||
|
||||
function collectEmptyStringPaths(value, pathLabel = '$') {
|
||||
/*
|
||||
Empty-string policy is intentionally tested by path because these four
|
||||
fields are exceptional for security or visible behavior, not omissions in
|
||||
the legacy-style default document. Walking the complete value also catches
|
||||
an accidentally blank field inside a pre-populated example collection.
|
||||
*/
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item, index) => collectEmptyStringPaths(item, `${pathLabel}[${index}]`));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value).flatMap(([key, childValue]) => (
|
||||
collectEmptyStringPaths(childValue, `${pathLabel}.${key}`)
|
||||
));
|
||||
}
|
||||
return value === '' ? [pathLabel] : [];
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
|
||||
});
|
||||
|
||||
test('safe defaults form a complete valid configuration with integrations disabled', () => {
|
||||
assert.doesNotThrow(() => assertValidConfig(defaultConfig));
|
||||
assert.equal(defaultConfig.discord.enabled, false);
|
||||
assert.equal(defaultConfig.homeAssistant.enabled, false);
|
||||
assert.equal(defaultConfig.ptzCamera.enabled, false);
|
||||
assert.equal(defaultConfig.balanceBoard.enabled, false);
|
||||
});
|
||||
|
||||
test('legacy-style defaults populate every non-secret and inactive-content value', () => {
|
||||
/*
|
||||
Credentials must not masquerade as configured, and driver HTML would be
|
||||
immediately visible without an enable switch. Every other free-form value
|
||||
should match the populated template behavior operators had with YAML.
|
||||
*/
|
||||
assert.deepEqual(collectEmptyStringPaths(defaultConfig), [
|
||||
'$.homeAssistant.token',
|
||||
'$.ptzCamera.password',
|
||||
'$.discord.token',
|
||||
'$.driverAd.html',
|
||||
]);
|
||||
assert.ok(defaultConfig.interInstance.directoryUrls.length > 0);
|
||||
assert.ok(defaultConfig.homeAssistant.entities.length > 0);
|
||||
assert.ok(defaultConfig.homeAssistant.buttons.length > 0);
|
||||
assert.ok(defaultConfig.roomCameras.cameras.length > 0);
|
||||
assert.ok(defaultConfig.socials.links.length > 0);
|
||||
});
|
||||
|
||||
test('service definitions determine document order and write-only secret handling', () => {
|
||||
/*
|
||||
The generic browser form and backend persistence both consume this one
|
||||
assembled schema. Guarding composition order and derived secret paths here
|
||||
prevents either consumer from needing its own parallel registry.
|
||||
*/
|
||||
assert.deepEqual(Object.keys(defaultConfig), definitions.map(({ key }) => key));
|
||||
assert.deepEqual(Object.keys(rootSchema.properties), Object.keys(defaultConfig));
|
||||
assert.deepEqual(secretPaths, ['homeAssistant.token', 'ptzCamera.password', 'discord.token']);
|
||||
assert.equal(rootSchema.properties.homeAssistant.properties.token.writeOnly, true);
|
||||
assert.equal(rootSchema.properties.ptzCamera.properties.password.writeOnly, true);
|
||||
assert.equal(rootSchema.properties.discord.properties.token.writeOnly, true);
|
||||
});
|
||||
|
||||
test('every configuration section, collection, item, and option has an operator description', () => {
|
||||
/*
|
||||
New configuration remains self-documenting by default. Reporting every
|
||||
dotted path in one assertion gives a contributor an exact repair list and
|
||||
avoids recreating a separately maintained documentation registry.
|
||||
*/
|
||||
assert.deepEqual(collectUndocumentedSchemaPaths(rootSchema), []);
|
||||
});
|
||||
|
||||
test('empty installation-specific fields and array item inputs provide schema-owned examples', () => {
|
||||
/*
|
||||
The frontend derives placeholders from these examples generically. Keeping
|
||||
this assertion beside schema composition prevents an empty, unexplained box
|
||||
from returning when a service adds configuration in the future.
|
||||
*/
|
||||
assert.deepEqual(collectSchemaPathsMissingInputExamples(rootSchema, defaultConfig), []);
|
||||
});
|
||||
|
||||
test('service definitions generate public feature paths without a separate registry', () => {
|
||||
/*
|
||||
This order follows the one configuration document, including nested Neato
|
||||
and lift definitions beneath Home Assistant. The assertion makes duplicate,
|
||||
omitted, or centrally reintroduced feature names visible during review.
|
||||
*/
|
||||
assert.deepEqual(featureDefinitions, [
|
||||
{ key: 'interInstance', path: ['interInstance', 'enabled'] },
|
||||
{ key: 'barcodeGames', path: ['barcodeGames', 'enabled'] },
|
||||
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
|
||||
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
|
||||
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
|
||||
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
|
||||
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
|
||||
{ key: 'kinect', path: ['kinect', 'enabled'] },
|
||||
{ key: 'balanceBoard', path: ['balanceBoard', 'enabled'] },
|
||||
{ key: 'buttonBox', path: ['buttonBox', 'enabled'] },
|
||||
{ key: 'barcodeScanner', path: ['barcodeScanner', 'enabled'] },
|
||||
{ key: 'discord', path: ['discord', 'enabled'] },
|
||||
{ key: 'socials', path: ['socials', 'enabled'] },
|
||||
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('generated feature flags use only each declared enabled switch', () => {
|
||||
/*
|
||||
This deliberately describes services without usable credentials, devices,
|
||||
or enabled parents. Readiness belongs to runtime health, so the generated
|
||||
public flags must still preserve each operator-selected switch exactly.
|
||||
*/
|
||||
const flags = getFeatureFlags({
|
||||
homeAssistant: {
|
||||
enabled: false,
|
||||
lift: { enabled: true },
|
||||
neato: { enabled: true },
|
||||
},
|
||||
roomCameras: { enabled: true, cameras: [] },
|
||||
barcodeScanner: { enabled: false },
|
||||
barcodeGames: { enabled: true },
|
||||
socials: { enabled: true, links: [] },
|
||||
ptzCamera: { enabled: true, host: '', username: '', password: '' },
|
||||
discord: { enabled: true, token: '' },
|
||||
});
|
||||
|
||||
assert.equal(flags.homeAssistant, false);
|
||||
assert.equal(flags.lift, true);
|
||||
assert.equal(flags.neato, true);
|
||||
assert.equal(flags.roomCameras, true);
|
||||
assert.equal(flags.barcodeScanner, false);
|
||||
assert.equal(flags.barcodeGames, true);
|
||||
assert.equal(flags.socials, true);
|
||||
assert.equal(flags.ptzCamera, true);
|
||||
assert.equal(flags.discord, true);
|
||||
});
|
||||
|
||||
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
|
||||
const normalized = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video' } });
|
||||
// Missing fields now receive the same populated template defaults as a new
|
||||
// installation; normalization must not silently revert this one collection
|
||||
// to the former empty-safe-default policy.
|
||||
assert.deepEqual(normalized.media.additionalHosts, ['rover.example.com', 'media-server.local']);
|
||||
assert.doesNotThrow(() => assertValidConfig(normalized));
|
||||
|
||||
const invalid = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video', misspelledHost: 'x' } });
|
||||
assert.throws(() => assertValidConfig(invalid), (error) => {
|
||||
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
|
||||
assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost')));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('full-document updates preserve secrets and reject a stale browser revision', () => {
|
||||
const database = createTestDatabase();
|
||||
const initial = database.getActiveConfigurationRecord();
|
||||
const tokenRevision = database.updateConfiguration({
|
||||
value: database.getClientConfiguration().config,
|
||||
expectedRevision: initial.revision,
|
||||
actor: 'test',
|
||||
secretOperations: {
|
||||
'discord.token': { action: 'replace', value: 'super-secret-token' },
|
||||
},
|
||||
});
|
||||
|
||||
const client = database.getClientConfiguration();
|
||||
assert.equal(client.config.discord.token, '');
|
||||
assert.equal(client.configuredSecrets['discord.token'], true);
|
||||
const editedConfiguration = structuredClone(client.config);
|
||||
editedConfiguration.discord.enabled = true;
|
||||
const nextRevision = database.updateConfiguration({
|
||||
value: editedConfiguration,
|
||||
expectedRevision: tokenRevision,
|
||||
actor: 'test',
|
||||
});
|
||||
assert.equal(database.getActiveConfigurationRecord().config.discord.token, 'super-secret-token');
|
||||
assert.throws(() => database.updateConfiguration({
|
||||
value: editedConfiguration,
|
||||
expectedRevision: tokenRevision,
|
||||
actor: 'stale-test',
|
||||
}), (error) => error.code === 'CONFIG_REVISION_CONFLICT' && error.currentRevision === nextRevision);
|
||||
|
||||
const rollbackRevision = database.restoreConfigurationRevision({
|
||||
revision: tokenRevision,
|
||||
expectedRevision: nextRevision,
|
||||
actor: 'rollback-test',
|
||||
});
|
||||
assert.ok(rollbackRevision > nextRevision);
|
||||
assert.equal(database.getActiveConfigurationRecord().config.discord.enabled, false);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('administrator storage never exposes hashes or removes the final lockdown administrator', () => {
|
||||
const database = createTestDatabase();
|
||||
const lockdown = database.createAdministrator({
|
||||
username: 'owner',
|
||||
passwordHash: '$2b$10$example',
|
||||
role: 'lockdown',
|
||||
});
|
||||
const listed = database.listAdministrators();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(Object.hasOwn(listed[0], 'passwordHash'), false);
|
||||
assert.throws(() => database.deleteAdministrator(lockdown.id, 'test'), /final lockdown administrator/);
|
||||
assert.throws(() => database.updateAdministrator(lockdown.id, { role: 'admin' }, 'test'), /final lockdown administrator/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('an explicitly uploaded YAML imports current fields, ignores obsolete keys, and preserves bcrypt hashes exactly once', () => {
|
||||
const yamlText = `
|
||||
admins:
|
||||
- username: owner
|
||||
password_hash: "$2b$10$preservedHash"
|
||||
discord_id: "1234"
|
||||
lockdown: true
|
||||
timezone: America/Chicago
|
||||
media:
|
||||
whepBaseUrl: http://localhost:8889/video
|
||||
overseerControl:
|
||||
enabled: false
|
||||
heartbeatMs: 30000
|
||||
alwaysRunModel: false
|
||||
homeAssistant:
|
||||
neato:
|
||||
enabled: false
|
||||
brainslugHost: neato-vacuum.local
|
||||
brainslugKey: retired-secret
|
||||
brainslugLogFile: /tmp/retired.log
|
||||
roomCameras:
|
||||
enabled: true
|
||||
cameras:
|
||||
- id: stream-only
|
||||
name: Stream-only camera
|
||||
streamUrl: http://camera.local/stream.mjpg
|
||||
discord:
|
||||
channels:
|
||||
chatBridge: "123456789012345678"
|
||||
roles:
|
||||
stalker: "123456789012345678"
|
||||
fleetReports:
|
||||
discord:
|
||||
immediateCriticalAlerts: true
|
||||
`;
|
||||
const parsed = parseConfigurationFile(yamlText);
|
||||
assert.equal(parsed.config.timezone, 'America/Chicago');
|
||||
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
|
||||
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'alwaysRunModel'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.homeAssistant.neato, 'brainslugHost'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.discord.channels, 'chatBridge'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.discord.roles, 'stalker'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.fleetReports.discord, 'immediateCriticalAlerts'), false);
|
||||
assert.deepEqual(parsed.config.roomCameras.cameras, [{
|
||||
id: 'stream-only',
|
||||
name: 'Stream-only camera',
|
||||
streamUrl: 'http://camera.local/stream.mjpg',
|
||||
}]);
|
||||
|
||||
const database = createTestDatabase();
|
||||
const result = importConfigurationFile({ text: yamlText, database });
|
||||
assert.equal(result.administratorCount, 1);
|
||||
assert.equal(database.findAdministratorForAuthentication('OWNER').passwordHash, '$2b$10$preservedHash');
|
||||
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('uploaded YAML still rejects invalid values for fields in the current schema', () => {
|
||||
const yamlText = `
|
||||
admins:
|
||||
- username: owner
|
||||
password_hash: "$2b$10$preservedHash"
|
||||
lockdown: true
|
||||
bandwidthSavings:
|
||||
multiTabProtection: unsupported-mode
|
||||
`;
|
||||
|
||||
assert.throws(() => parseConfigurationFile(yamlText), (error) => {
|
||||
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
|
||||
assert.ok(error.validationErrors.some((entry) => entry.path === '/bandwidthSavings/multiTabProtection'));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Configuration File Importer
|
||||
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database.
|
||||
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files.
|
||||
const yaml = require('js-yaml');
|
||||
const { rootSchema, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
|
||||
function keepCurrentSchemaFields(value, schema) {
|
||||
/*
|
||||
An uploaded file is only a convenient seed for the current configuration;
|
||||
it is not a second schema or a historical migration framework. Legacy YAML
|
||||
was permissive, so real installations naturally contain keys left behind
|
||||
by removed features. At object boundaries, copy only properties that exist
|
||||
in today's schema and recursively apply the same rule to nested objects and
|
||||
array items. Known fields retain their original values and are validated
|
||||
normally afterward, so this cannot hide a malformed current setting.
|
||||
*/
|
||||
if (schema?.type === 'object') {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
||||
return Object.fromEntries(Object.entries(schema.properties || {})
|
||||
.filter(([key]) => Object.hasOwn(value, key))
|
||||
.map(([key, childSchema]) => [key, keepCurrentSchemaFields(value[key], childSchema)]));
|
||||
}
|
||||
|
||||
if (schema?.type === 'array') {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.map((item) => keepCurrentSchemaFields(item, schema.items));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUploadedAdministrator(entry, index) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
throw new Error(`Administrator ${index + 1} must be an object.`);
|
||||
}
|
||||
const username = String(entry.username || '').trim();
|
||||
const passwordHash = String(entry.password_hash || '').trim();
|
||||
if (!username || !passwordHash) {
|
||||
throw new Error(`Administrator ${index + 1} requires username and password_hash.`);
|
||||
}
|
||||
return {
|
||||
username,
|
||||
passwordHash,
|
||||
discordId: String(entry.discord_id || '').trim(),
|
||||
role: entry.lockdown ? 'lockdown' : 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigurationFile(text) {
|
||||
const parsed = yaml.load(String(text || ''));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('The configuration file must contain a YAML object.');
|
||||
}
|
||||
|
||||
const administrators = Array.isArray(parsed.admins)
|
||||
? parsed.admins.map(normalizeUploadedAdministrator)
|
||||
: [];
|
||||
const configInput = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
||||
);
|
||||
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
|
||||
|
||||
// Filtering applies only to nonexistent keys. Values retained for current
|
||||
// schema fields still have to satisfy every type, range, and format rule
|
||||
// before the importer can atomically initialize the database.
|
||||
assertValidConfig(config);
|
||||
if (!administrators.some((admin) => admin.role === 'lockdown')) {
|
||||
throw new Error('The configuration file must contain at least one lockdown administrator.');
|
||||
}
|
||||
return { config, administrators };
|
||||
}
|
||||
|
||||
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
|
||||
const result = parseConfigurationFile(text);
|
||||
const revision = database.importConfigurationFile({
|
||||
config: result.config,
|
||||
administrators: result.administrators,
|
||||
actor,
|
||||
source,
|
||||
});
|
||||
return {
|
||||
revision,
|
||||
administratorCount: result.administrators.length,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseConfigurationFile,
|
||||
importConfigurationFile,
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
// Configuration Database
|
||||
// Purpose: Persists complete immutable configuration revisions, administrator accounts, and administrative audit history.
|
||||
// Scope: Owns SQLite transactions and invariants; transport authorization and password hashing remain service concerns.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const { resolveDataPath } = require('../helpers/dataPaths');
|
||||
const { applySchemaMigrations } = require('./migrations');
|
||||
const {
|
||||
defaultConfig,
|
||||
secretPaths,
|
||||
clone,
|
||||
normalizeConfig,
|
||||
assertValidConfig,
|
||||
} = require('./validation');
|
||||
|
||||
const DEFAULT_DATABASE_PATH = resolveDataPath('configuration.sqlite');
|
||||
|
||||
function normalizeUsername(value) {
|
||||
const username = String(value || '').trim();
|
||||
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(username)) {
|
||||
throw new Error('Administrator username must be 1-64 letters, numbers, dots, underscores, or hyphens.');
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function normalizeRole(value) {
|
||||
if (value === 'admin' || value === 'lockdown') return value;
|
||||
throw new Error('Administrator role must be admin or lockdown.');
|
||||
}
|
||||
|
||||
function splitPath(value) {
|
||||
return String(value || '').split('.').filter(Boolean);
|
||||
}
|
||||
|
||||
function getAtPath(object, dottedPath) {
|
||||
return splitPath(dottedPath).reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
function setAtPath(object, dottedPath, value) {
|
||||
const parts = splitPath(dottedPath);
|
||||
let cursor = object;
|
||||
parts.slice(0, -1).forEach((key) => {
|
||||
if (!cursor[key] || typeof cursor[key] !== 'object') cursor[key] = {};
|
||||
cursor = cursor[key];
|
||||
});
|
||||
cursor[parts.at(-1)] = value;
|
||||
}
|
||||
|
||||
function redactConfiguration(config) {
|
||||
const redacted = clone(config);
|
||||
const configuredSecrets = {};
|
||||
secretPaths.forEach((secretPath) => {
|
||||
configuredSecrets[secretPath] = Boolean(getAtPath(config, secretPath));
|
||||
setAtPath(redacted, secretPath, '');
|
||||
});
|
||||
return { config: redacted, configuredSecrets };
|
||||
}
|
||||
|
||||
function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } = {}) {
|
||||
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
||||
const db = new Database(databasePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
applySchemaMigrations(db);
|
||||
|
||||
const readActiveStatement = db.prepare(`
|
||||
SELECT r.id, r.config_json, r.created_at, r.actor, r.source
|
||||
FROM configuration_state s
|
||||
JOIN configuration_revisions r ON r.id = s.active_revision_id
|
||||
WHERE s.singleton = 1
|
||||
`);
|
||||
const insertRevisionStatement = db.prepare(`
|
||||
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const activateRevisionStatement = db.prepare(`
|
||||
INSERT INTO configuration_state (singleton, active_revision_id)
|
||||
VALUES (1, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET active_revision_id = excluded.active_revision_id
|
||||
`);
|
||||
const insertAuditStatement = db.prepare(`
|
||||
INSERT INTO administrative_audit_events (created_at, actor, action, details_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
function writeAudit(actor, action, details = {}) {
|
||||
/*
|
||||
Callers pass deliberately small, already-redacted metadata. Configuration
|
||||
values and password hashes never belong in audit details because audit
|
||||
history is routinely displayed and retained longer than request bodies.
|
||||
*/
|
||||
insertAuditStatement.run(Date.now(), String(actor || 'system'), String(action), JSON.stringify(details));
|
||||
}
|
||||
|
||||
const commitRevisionTransaction = db.transaction((config, metadata) => {
|
||||
const current = readActiveStatement.get();
|
||||
if (metadata.expectedRevision != null && Number(metadata.expectedRevision) !== Number(current?.id)) {
|
||||
const error = new Error('Configuration changed in another session. Reload before saving.');
|
||||
error.code = 'CONFIG_REVISION_CONFLICT';
|
||||
error.currentRevision = current?.id || null;
|
||||
throw error;
|
||||
}
|
||||
assertValidConfig(config);
|
||||
const createdAt = Date.now();
|
||||
const inserted = insertRevisionStatement.run(
|
||||
JSON.stringify(config),
|
||||
createdAt,
|
||||
String(metadata.actor || 'system'),
|
||||
String(metadata.source || 'admin'),
|
||||
);
|
||||
activateRevisionStatement.run(inserted.lastInsertRowid);
|
||||
writeAudit(metadata.actor, 'configuration.saved', {
|
||||
revision: Number(inserted.lastInsertRowid),
|
||||
source: String(metadata.source || 'admin'),
|
||||
});
|
||||
return Number(inserted.lastInsertRowid);
|
||||
});
|
||||
|
||||
const initialActiveRow = readActiveStatement.get();
|
||||
if (!initialActiveRow) {
|
||||
commitRevisionTransaction(clone(defaultConfig), {
|
||||
actor: 'system',
|
||||
source: 'first-boot-defaults',
|
||||
});
|
||||
} else {
|
||||
/*
|
||||
New service-owned fields receive their declared defaults as a new revision on
|
||||
startup. Unknown or newly invalid fields still fail validation; this is a
|
||||
forward schema evolution path, not a compatibility layer that discards
|
||||
data it no longer understands.
|
||||
*/
|
||||
const storedConfig = JSON.parse(initialActiveRow.config_json);
|
||||
const normalizedConfig = normalizeConfig(storedConfig);
|
||||
assertValidConfig(normalizedConfig);
|
||||
if (JSON.stringify(normalizedConfig) !== JSON.stringify(storedConfig)) {
|
||||
commitRevisionTransaction(normalizedConfig, {
|
||||
expectedRevision: Number(initialActiveRow.id),
|
||||
actor: 'system',
|
||||
source: 'registered-defaults',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveConfigurationRecord() {
|
||||
const row = readActiveStatement.get();
|
||||
if (!row) throw new Error('Active configuration revision is missing.');
|
||||
return {
|
||||
revision: Number(row.id),
|
||||
config: JSON.parse(row.config_json),
|
||||
createdAt: Number(row.created_at),
|
||||
actor: row.actor,
|
||||
source: row.source,
|
||||
};
|
||||
}
|
||||
|
||||
function getClientConfiguration() {
|
||||
const record = getActiveConfigurationRecord();
|
||||
const redacted = redactConfiguration(record.config);
|
||||
return { ...record, ...redacted };
|
||||
}
|
||||
|
||||
function updateConfiguration({ value, expectedRevision, secretOperations = {}, actor }) {
|
||||
const active = getActiveConfigurationRecord();
|
||||
const candidate = clone(value);
|
||||
|
||||
/*
|
||||
The browser edits one complete document, but its copy contains blank
|
||||
placeholders in place of every stored secret. Restore all current secret
|
||||
values first, then apply only explicit replace or clear operations. This
|
||||
keeps the full-document save model simple without ever sending an
|
||||
existing credential back to the browser.
|
||||
*/
|
||||
secretPaths.forEach((secretPath) => {
|
||||
setAtPath(candidate, secretPath, getAtPath(active.config, secretPath));
|
||||
const operation = secretOperations[secretPath];
|
||||
if (!operation) return;
|
||||
if (operation.action === 'clear') setAtPath(candidate, secretPath, '');
|
||||
else if (operation.action === 'replace' && typeof operation.value === 'string' && operation.value.length > 0) {
|
||||
setAtPath(candidate, secretPath, operation.value);
|
||||
} else {
|
||||
throw new Error(`Invalid secret operation for ${secretPath}.`);
|
||||
}
|
||||
});
|
||||
|
||||
return commitRevisionTransaction(candidate, {
|
||||
expectedRevision,
|
||||
actor,
|
||||
source: 'admin-ui',
|
||||
});
|
||||
}
|
||||
|
||||
function listConfigurationRevisions({ limit = 100 } = {}) {
|
||||
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 100)));
|
||||
return db.prepare(`
|
||||
SELECT id, created_at, actor, source
|
||||
FROM configuration_revisions
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit).map((row) => ({
|
||||
revision: Number(row.id),
|
||||
createdAt: Number(row.created_at),
|
||||
actor: row.actor,
|
||||
source: row.source,
|
||||
}));
|
||||
}
|
||||
|
||||
function restoreConfigurationRevision({ revision, expectedRevision, actor }) {
|
||||
const row = db.prepare('SELECT config_json FROM configuration_revisions WHERE id = ?').get(Number(revision));
|
||||
if (!row) throw new Error('Configuration revision not found.');
|
||||
const restoredConfig = JSON.parse(row.config_json);
|
||||
return commitRevisionTransaction(restoredConfig, {
|
||||
expectedRevision,
|
||||
actor,
|
||||
source: `rollback-from-${Number(revision)}`,
|
||||
});
|
||||
}
|
||||
|
||||
function listAdministrators() {
|
||||
return db.prepare(`
|
||||
SELECT id, username, discord_id, role, created_at, updated_at
|
||||
FROM administrators
|
||||
ORDER BY username COLLATE NOCASE
|
||||
`).all().map((row) => ({
|
||||
id: Number(row.id),
|
||||
username: row.username,
|
||||
discordId: row.discord_id || '',
|
||||
role: row.role,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}));
|
||||
}
|
||||
|
||||
function findAdministratorForAuthentication(username) {
|
||||
const normalized = String(username || '').trim();
|
||||
if (!normalized) return null;
|
||||
const row = db.prepare(`
|
||||
SELECT id, username, password_hash, discord_id, role
|
||||
FROM administrators
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
`).get(normalized);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
discordId: row.discord_id || '',
|
||||
role: row.role,
|
||||
};
|
||||
}
|
||||
|
||||
function countLockdownAdministrators() {
|
||||
return Number(db.prepare("SELECT COUNT(*) AS count FROM administrators WHERE role = 'lockdown'").get().count);
|
||||
}
|
||||
|
||||
const createAdministratorTransaction = db.transaction((admin, actor, audit = true) => {
|
||||
const username = normalizeUsername(admin.username);
|
||||
const role = normalizeRole(admin.role);
|
||||
const passwordHash = String(admin.passwordHash || '').trim();
|
||||
if (!passwordHash) throw new Error('Administrator password hash is required.');
|
||||
const now = Date.now();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO administrators (username, password_hash, discord_id, role, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(username, passwordHash, String(admin.discordId || '').trim() || null, role, now, now);
|
||||
if (audit) writeAudit(actor, 'administrator.created', { administratorId: Number(result.lastInsertRowid), username, role });
|
||||
return Number(result.lastInsertRowid);
|
||||
});
|
||||
|
||||
function createAdministrator(admin, actor = 'system') {
|
||||
const id = createAdministratorTransaction(admin, actor, true);
|
||||
return listAdministrators().find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
const updateAdministratorTransaction = db.transaction((id, changes, actor) => {
|
||||
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
|
||||
if (!current) throw new Error('Administrator not found.');
|
||||
const username = changes.username == null ? current.username : normalizeUsername(changes.username);
|
||||
const role = changes.role == null ? current.role : normalizeRole(changes.role);
|
||||
const discordId = changes.discordId == null ? current.discord_id : String(changes.discordId || '').trim() || null;
|
||||
const passwordHash = changes.passwordHash == null ? current.password_hash : String(changes.passwordHash || '').trim();
|
||||
if (!passwordHash) throw new Error('Administrator password hash is required.');
|
||||
if (current.role === 'lockdown' && role !== 'lockdown' && countLockdownAdministrators() <= 1) {
|
||||
throw new Error('The final lockdown administrator cannot be demoted.');
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE administrators
|
||||
SET username = ?, password_hash = ?, discord_id = ?, role = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(username, passwordHash, discordId, role, Date.now(), Number(id));
|
||||
writeAudit(actor, 'administrator.updated', { administratorId: Number(id), username, role, passwordChanged: changes.passwordHash != null });
|
||||
});
|
||||
|
||||
function updateAdministrator(id, changes, actor) {
|
||||
updateAdministratorTransaction(id, changes || {}, actor || 'system');
|
||||
return listAdministrators().find((entry) => entry.id === Number(id));
|
||||
}
|
||||
|
||||
const deleteAdministratorTransaction = db.transaction((id, actor) => {
|
||||
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
|
||||
if (!current) throw new Error('Administrator not found.');
|
||||
if (current.role === 'lockdown' && countLockdownAdministrators() <= 1) {
|
||||
throw new Error('The final lockdown administrator cannot be removed.');
|
||||
}
|
||||
db.prepare('DELETE FROM administrators WHERE id = ?').run(Number(id));
|
||||
writeAudit(actor, 'administrator.deleted', { administratorId: Number(id), username: current.username, role: current.role });
|
||||
});
|
||||
|
||||
function deleteAdministrator(id, actor = 'system') {
|
||||
deleteAdministratorTransaction(id, actor);
|
||||
}
|
||||
|
||||
function isSetupComplete() {
|
||||
return countLockdownAdministrators() > 0;
|
||||
}
|
||||
|
||||
function listAuditEvents({ limit = 200 } = {}) {
|
||||
const safeLimit = Math.max(1, Math.min(1000, Math.floor(Number(limit) || 200)));
|
||||
return db.prepare(`
|
||||
SELECT id, created_at, actor, action, details_json
|
||||
FROM administrative_audit_events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(safeLimit).map((row) => ({
|
||||
id: Number(row.id),
|
||||
createdAt: Number(row.created_at),
|
||||
actor: row.actor,
|
||||
action: row.action,
|
||||
details: JSON.parse(row.details_json),
|
||||
}));
|
||||
}
|
||||
|
||||
const importConfigurationFileTransaction = db.transaction(({ config, administrators, actor, source }) => {
|
||||
// A setup upload initializes an empty installation; it is deliberately not
|
||||
// a general-purpose replacement path for a running server's configuration.
|
||||
if (isSetupComplete()) throw new Error('A configuration file cannot replace an initialized installation.');
|
||||
const normalized = assertValidConfig(normalizeConfig(config));
|
||||
const revision = commitRevisionTransaction(normalized, {
|
||||
expectedRevision: getActiveConfigurationRecord().revision,
|
||||
actor,
|
||||
source,
|
||||
});
|
||||
administrators.forEach((admin) => createAdministratorTransaction(admin, actor, false));
|
||||
if (!isSetupComplete()) throw new Error('The configuration file must contain at least one lockdown administrator.');
|
||||
writeAudit(actor, 'setup.configuration-file-imported', { revision, administratorCount: administrators.length, source });
|
||||
return revision;
|
||||
});
|
||||
|
||||
function importConfigurationFile(payload) {
|
||||
return importConfigurationFileTransaction(payload);
|
||||
}
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
getActiveConfigurationRecord,
|
||||
getClientConfiguration,
|
||||
updateConfiguration,
|
||||
listConfigurationRevisions,
|
||||
restoreConfigurationRevision,
|
||||
listAdministrators,
|
||||
findAdministratorForAuthentication,
|
||||
createAdministrator,
|
||||
updateAdministrator,
|
||||
deleteAdministrator,
|
||||
countLockdownAdministrators,
|
||||
isSetupComplete,
|
||||
listAuditEvents,
|
||||
importConfigurationFile,
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_DATABASE_PATH,
|
||||
createConfigurationDatabase,
|
||||
redactConfiguration,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
// Complete Configuration Definition
|
||||
// Purpose: Assembles service-owned configuration fragments into the one ordered document used by storage, validation, and the admin UI.
|
||||
// Scope: Controls top-level order and composition only; each owning service defines the meaning, defaults, and schema of its own values.
|
||||
const { strictObject } = require('./schemaHelpers');
|
||||
const sessionConfiguration = require('../services/sessionService/configuration');
|
||||
const interInstance = require('../services/interInstanceService/configuration');
|
||||
const llmCommentary = require('../services/llmCommentaryService/configuration');
|
||||
const overseerControl = require('../services/overseerControlService/configuration');
|
||||
const barcodeGames = require('../services/barcodeGameService/configuration');
|
||||
const media = require('../services/mediaMtxService/configuration');
|
||||
const bandwidthSavings = require('../helpers/bandwidthSavings.configuration');
|
||||
const audioForward = require('../services/audioForwardService/configuration');
|
||||
const audioLevels = require('../services/audioLevelsService/configuration');
|
||||
const homeAssistant = require('../services/homeAssistantService/configuration');
|
||||
const roomCameras = require('../services/roomCameraService/configuration');
|
||||
const ptzCamera = require('../services/ptzCameraService/configuration');
|
||||
const kinect = require('../services/kinectService/configuration');
|
||||
const balanceBoard = require('../services/balanceBoardService/configuration');
|
||||
const buttonBox = require('../services/buttonBoxService/configuration');
|
||||
const barcodeScanner = require('../services/barcodeScannerService/configuration');
|
||||
const commands = require('../services/operatorCommandService/configuration');
|
||||
const discord = require('../services/discordBotService/configuration');
|
||||
const fleetReports = require('../services/fleetReportService/configuration');
|
||||
|
||||
/*
|
||||
Object property order is preserved by JSON serialization and JSON Schema
|
||||
consumers. Keeping this explicit list in legacy-YAML order makes the generic
|
||||
admin form predictable without creating a second frontend ordering system.
|
||||
The session service owns three non-adjacent public-presentation values, so
|
||||
those fragments are placed independently at their historical positions.
|
||||
*/
|
||||
const definitions = [
|
||||
sessionConfiguration.timezone,
|
||||
interInstance,
|
||||
llmCommentary,
|
||||
overseerControl,
|
||||
barcodeGames,
|
||||
media,
|
||||
bandwidthSavings,
|
||||
audioForward,
|
||||
audioLevels,
|
||||
homeAssistant,
|
||||
roomCameras,
|
||||
ptzCamera,
|
||||
kinect,
|
||||
balanceBoard,
|
||||
buttonBox,
|
||||
barcodeScanner,
|
||||
commands,
|
||||
discord,
|
||||
sessionConfiguration.socials,
|
||||
sessionConfiguration.driverAd,
|
||||
fleetReports,
|
||||
];
|
||||
|
||||
const defaultConfig = Object.fromEntries(
|
||||
definitions.map(({ key, defaultValue }) => [key, defaultValue]),
|
||||
);
|
||||
const properties = Object.fromEntries(
|
||||
definitions.map(({ key, schema }) => [key, schema]),
|
||||
);
|
||||
const rootSchema = strictObject(properties, {
|
||||
title: 'Configuration',
|
||||
description: 'Complete server configuration. Changes are validated and saved as one revision, then loaded when the application restarts.',
|
||||
required: definitions.map(({ key }) => key),
|
||||
});
|
||||
|
||||
function collectFeatureDefinitions(definition, parentPath = []) {
|
||||
const configPath = [...parentPath, definition.key];
|
||||
const features = [];
|
||||
|
||||
if (definition.feature === true) {
|
||||
/*
|
||||
A feature declaration is intentionally only a boolean marker. Its public
|
||||
name is the configuration item's key and its value is that item's own
|
||||
enabled field, so a service cannot introduce a second enablement rule in
|
||||
metadata. Failing during definition assembly catches an invalid marker at
|
||||
startup instead of publishing an undefined capability to browsers.
|
||||
*/
|
||||
if (definition.schema?.properties?.enabled?.type !== 'boolean') {
|
||||
throw new Error(`Configuration feature ${definition.key} must define a boolean enabled field.`);
|
||||
}
|
||||
features.push({ key: definition.key, path: [...configPath, 'enabled'] });
|
||||
}
|
||||
|
||||
const nestedDefinitions = Array.isArray(definition.nestedDefinitions)
|
||||
? definition.nestedDefinitions
|
||||
: [];
|
||||
nestedDefinitions.forEach((nestedDefinition) => {
|
||||
features.push(...collectFeatureDefinitions(nestedDefinition, configPath));
|
||||
});
|
||||
return features;
|
||||
}
|
||||
|
||||
/*
|
||||
This derived list replaces the old hand-maintained feature registry. Top-level
|
||||
and nested configuration owners opt in beside their schema, while this module
|
||||
only preserves their already-declared document paths.
|
||||
*/
|
||||
const featureDefinitions = definitions.flatMap((definition) => collectFeatureDefinitions(definition));
|
||||
|
||||
function collectWriteOnlyPaths(schema, prefix = '') {
|
||||
/*
|
||||
Secrets are declared once, beside the service field that consumes them.
|
||||
Walking object properties produces the dotted paths needed for redaction
|
||||
and update handling without maintaining a parallel secret registry.
|
||||
*/
|
||||
if (!schema || typeof schema !== 'object') return [];
|
||||
if (schema.writeOnly === true) return prefix ? [prefix] : [];
|
||||
if (schema.type !== 'object' || !schema.properties) return [];
|
||||
return Object.entries(schema.properties).flatMap(([key, childSchema]) => (
|
||||
collectWriteOnlyPaths(childSchema, prefix ? `${prefix}.${key}` : key)
|
||||
));
|
||||
}
|
||||
|
||||
const secretPaths = collectWriteOnlyPaths(rootSchema);
|
||||
|
||||
module.exports = {
|
||||
definitions,
|
||||
defaultConfig,
|
||||
rootSchema,
|
||||
secretPaths,
|
||||
featureDefinitions,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Configuration Service
|
||||
// Purpose: Exposes the process-wide synchronous configuration snapshot and the underlying administration store.
|
||||
// Scope: Keeps existing require-time startup semantics while making SQLite the only runtime configuration source.
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { rootSchema, featureDefinitions } = require('./definition');
|
||||
|
||||
let singleton;
|
||||
let runtimeConfigurationRevision = null;
|
||||
|
||||
function getConfigurationDatabase() {
|
||||
if (!singleton) {
|
||||
singleton = createConfigurationDatabase();
|
||||
/*
|
||||
Capture the active revision once when the process opens its configuration
|
||||
store. Later admin saves are intentionally restart-bound, so comparing
|
||||
against this value gives every reconnecting browser an authoritative
|
||||
pending-restart indicator.
|
||||
*/
|
||||
runtimeConfigurationRevision = singleton.getActiveConfigurationRecord().revision;
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
function getRuntimeConfigurationRevision() {
|
||||
getConfigurationDatabase();
|
||||
return runtimeConfigurationRevision;
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
/*
|
||||
Services intentionally receive one coherent snapshot for this process.
|
||||
Configuration commits are restart-bound, so re-reading during runtime would
|
||||
let only some modules observe the new revision and create a split-brain
|
||||
process. The database remains queryable through its administrative API.
|
||||
*/
|
||||
if (!loadConfig.cached) {
|
||||
loadConfig.cached = Object.freeze(getConfigurationDatabase().getActiveConfigurationRecord().config);
|
||||
}
|
||||
return loadConfig.cached;
|
||||
}
|
||||
|
||||
function getValueAtPath(value, path) {
|
||||
return path.reduce((current, key) => current?.[key], value);
|
||||
}
|
||||
|
||||
function getFeatureFlags(config = loadConfig()) {
|
||||
/*
|
||||
Feature definitions come directly from service-owned configuration metadata.
|
||||
Returning an explicit boolean map preserves the existing public session
|
||||
contract while ensuring the item's enabled field is its only source.
|
||||
*/
|
||||
return Object.fromEntries(featureDefinitions.map(({ key, path }) => [
|
||||
key,
|
||||
Boolean(getValueAtPath(config, path)),
|
||||
]));
|
||||
}
|
||||
|
||||
function isFeatureEnabled(featureName) {
|
||||
return Boolean(getFeatureFlags()[featureName]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigurationDatabase,
|
||||
getRuntimeConfigurationRevision,
|
||||
loadConfig,
|
||||
getFeatureFlags,
|
||||
isFeatureEnabled,
|
||||
rootSchema,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Configuration Database Migrations
|
||||
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
||||
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
|
||||
const migrations = [
|
||||
{
|
||||
version: 1,
|
||||
sql: `
|
||||
CREATE TABLE configuration_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
source TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE configuration_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
active_revision_id INTEGER NOT NULL REFERENCES configuration_revisions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE administrators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
discord_id TEXT,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'lockdown')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE administrative_audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
function applySchemaMigrations(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
|
||||
const record = db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)');
|
||||
|
||||
migrations.forEach((migration) => {
|
||||
if (applied.has(migration.version)) return;
|
||||
/*
|
||||
Schema SQL and its version marker are one transaction. A process failure
|
||||
can therefore retry the migration cleanly instead of finding a partially
|
||||
changed database whose version incorrectly appears current.
|
||||
*/
|
||||
db.transaction(() => {
|
||||
db.exec(migration.sql);
|
||||
record.run(migration.version, Date.now());
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
migrations,
|
||||
applySchemaMigrations,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Configuration Schema Helpers
|
||||
// Purpose: Keeps repetitive declarations in the complete strict JSON Schema readable.
|
||||
// Scope: Defines schema-building helpers only; validation and default application remain separate responsibilities.
|
||||
|
||||
function strictObject(properties, options = {}) {
|
||||
/*
|
||||
Configuration objects reject unknown keys at every level. A misspelled
|
||||
operator setting must fail loudly instead of looking saved while the server
|
||||
silently falls back to another value.
|
||||
*/
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
...(options.description ? { description: options.description } : {}),
|
||||
...(Array.isArray(options.required) ? { required: options.required } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function string(options = {}) {
|
||||
return { type: 'string', ...options };
|
||||
}
|
||||
|
||||
function nullableString(options = {}) {
|
||||
return { type: ['string', 'null'], ...options };
|
||||
}
|
||||
|
||||
function boolean(options = {}) {
|
||||
return { type: 'boolean', ...options };
|
||||
}
|
||||
|
||||
function integer(options = {}) {
|
||||
return { type: 'integer', ...options };
|
||||
}
|
||||
|
||||
function number(options = {}) {
|
||||
return { type: 'number', ...options };
|
||||
}
|
||||
|
||||
function stringArray(options = {}) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: string(options.item || {}),
|
||||
...options.array,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
strictObject,
|
||||
string,
|
||||
nullableString,
|
||||
boolean,
|
||||
integer,
|
||||
number,
|
||||
stringArray,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// Configuration Validation
|
||||
// Purpose: Validates and normalizes the one hierarchical configuration document.
|
||||
// Scope: Owns reusable validation behavior for the complete schema assembled from service definitions.
|
||||
const Ajv = require('ajv');
|
||||
const addFormats = require('ajv-formats');
|
||||
const { defaultConfig, rootSchema, secretPaths } = require('./definition');
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function mergeDefaults(defaultValue, suppliedValue) {
|
||||
/*
|
||||
Arrays are complete ordered values and must never be merged item-by-item.
|
||||
Plain objects recurse so a stored document can omit a newly introduced
|
||||
field and receive its safe default without discarding neighboring values.
|
||||
Unknown supplied keys are retained here so strict schema validation can
|
||||
report them instead of silently deleting operator input.
|
||||
*/
|
||||
if (Array.isArray(suppliedValue)) return clone(suppliedValue);
|
||||
if (!suppliedValue || typeof suppliedValue !== 'object' || Array.isArray(defaultValue)) {
|
||||
return suppliedValue === undefined ? clone(defaultValue) : suppliedValue;
|
||||
}
|
||||
|
||||
const result = clone(defaultValue);
|
||||
for (const [key, value] of Object.entries(suppliedValue)) {
|
||||
const fallback = defaultValue && typeof defaultValue === 'object' ? defaultValue[key] : undefined;
|
||||
result[key] = mergeDefaults(fallback, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||
addFormats(ajv);
|
||||
const validate = ajv.compile(rootSchema);
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors.map((error) => ({
|
||||
/*
|
||||
Ajv uses JSON Pointer instance paths. Prefixing an additional-property
|
||||
name makes the error point at the actual rejected field rather than only
|
||||
its containing object, which is more useful in the hierarchical form.
|
||||
*/
|
||||
path: error.keyword === 'additionalProperties'
|
||||
? `${error.instancePath}/${error.params.additionalProperty}`
|
||||
: error.instancePath || '/',
|
||||
message: error.message || 'Invalid value',
|
||||
keyword: error.keyword,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfig(input = {}) {
|
||||
return mergeDefaults(defaultConfig, input);
|
||||
}
|
||||
|
||||
function assertValidConfig(input) {
|
||||
if (validate(input)) return input;
|
||||
const error = new Error('Configuration validation failed.');
|
||||
error.code = 'CONFIG_VALIDATION_FAILED';
|
||||
error.validationErrors = formatValidationErrors(validate.errors);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/*
|
||||
Defaults are executable configuration, not documentation. Validate them at
|
||||
module load so a definition edit cannot make first boot fail later in an
|
||||
unrelated service require chain.
|
||||
*/
|
||||
assertValidConfig(defaultConfig);
|
||||
|
||||
module.exports = {
|
||||
defaultConfig,
|
||||
secretPaths,
|
||||
rootSchema,
|
||||
clone,
|
||||
normalizeConfig,
|
||||
assertValidConfig,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Bandwidth-Savings Configuration
|
||||
// Purpose: Defines the server-owned live-video and snapshot policy interpreted by this helper.
|
||||
// Scope: Exports configuration metadata without reading sessions or calculating policy.
|
||||
const { strictObject, string, boolean, integer } = require('../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'bandwidthSavings',
|
||||
defaultValue: {
|
||||
multiTabProtection: 'verifiedOnly',
|
||||
pauseHiddenRoverVideo: false,
|
||||
nonTurnVideo: { mode: 'snapshots', userThreshold: 0 },
|
||||
externalSpectatorVideo: 'snapshots',
|
||||
externalSpectatorAccess: 'on',
|
||||
},
|
||||
schema: strictObject({
|
||||
multiTabProtection: string({ description: 'Controls multiple active driver tabs: allowed permits everyone, verifiedOnly limits ordinary unverified users, and notAllowed limits all non-admin users.', enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
|
||||
pauseHiddenRoverVideo: boolean({ description: 'Stops a rover video player while its browser surface is hidden, reducing unnecessary client and server bandwidth.' }),
|
||||
nonTurnVideo: strictObject({
|
||||
mode: string({ description: 'snapshots replaces non-turn live rover video after the threshold is exceeded; live always permits live video.', enum: ['snapshots', 'live'] }),
|
||||
userThreshold: integer({ description: 'Maximum controllable-user count allowed before snapshot mode activates for non-turn viewers; zero activates it whenever any controllable user exists.', minimum: 0, maximum: 100000 }),
|
||||
}, { description: 'Controls whether users who are not currently driving receive live rover video or periodic snapshots.', required: ['mode', 'userThreshold'] }),
|
||||
externalSpectatorVideo: string({ description: 'Video delivered to non-local spectator pages: snapshots conserves upload bandwidth, while live permits continuous playback.', enum: ['snapshots', 'live'] }),
|
||||
externalSpectatorAccess: string({ description: 'Access for ordinary non-local spectators: off denies them, on permits them, verifiedOnly requires verification, and admin requires the spectator access grant. Local users and administrators remain allowed.', enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
|
||||
}, { title: 'Bandwidth savings', description: 'Defines server-owned policies for duplicate driver tabs and when live video is replaced with snapshots.', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
// Bandwidth Savings Helper
|
||||
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
|
||||
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
|
||||
// making individual services know raw YAML defaults or legacy config shapes.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
// making individual services duplicate the validated database configuration contract.
|
||||
const { loadConfig } = require('../configuration');
|
||||
|
||||
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
|
||||
const VIDEO_MODES = new Set(['snapshots', 'live']);
|
||||
@@ -21,9 +21,9 @@ const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||
|
||||
function normalizeEnum(value, allowed, fallback) {
|
||||
/*
|
||||
Config files are hand-edited on the server, so a typo should not crash the
|
||||
process or silently broaden access. Each option falls back to the current
|
||||
conservative behavior unless it exactly matches a known value.
|
||||
Tests and direct helper callers can still supply incomplete objects even
|
||||
though the database rejects invalid persisted values. Conservative fallback
|
||||
here keeps policy behavior safe at that secondary boundary.
|
||||
*/
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
return allowed.has(normalized) ? normalized : fallback;
|
||||
@@ -31,9 +31,9 @@ function normalizeEnum(value, allowed, fallback) {
|
||||
|
||||
function normalizeBoolean(value, fallback) {
|
||||
/*
|
||||
YAML booleans must stay real booleans. Treating strings such as "false" as
|
||||
truthy would silently enable a bandwidth policy that the operator intended
|
||||
to disable, so invalid values fall back to the documented server default.
|
||||
Treating strings such as "false" as truthy would silently enable a policy.
|
||||
Persisted values are schema-validated, while this guard protects direct
|
||||
helper calls and focused tests from the same JavaScript coercion trap.
|
||||
*/
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||
|
||||
function getBandwidthSavingsPolicy() {
|
||||
/*
|
||||
loadConfig() is cached by configLoader, so rebuilding this small object per
|
||||
loadConfig() is cached by configuration service, so rebuilding this small object per
|
||||
caller is cheap while still letting tests pass explicit config objects into
|
||||
buildBandwidthSavingsPolicy().
|
||||
*/
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Config Loader Helper
|
||||
// Purpose: Loads and validates YAML server configuration from configured paths. Scope: Provides normalized config access with sane defaults and cache behavior.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml');
|
||||
|
||||
let cachedConfig;
|
||||
|
||||
function loadConfig() {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
const file = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
cachedConfig = yaml.load(file);
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
// Feature Flags Helper
|
||||
// Purpose: Normalizes optional server feature availability from config in one place.
|
||||
// Scope: Keeps hardware/social visibility decisions out of individual UI panels and service callers.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
|
||||
function asBoolean(value, fallback = false) {
|
||||
/*
|
||||
Optional feature config is intentionally explicit. A missing `enabled` flag
|
||||
means "off" for specialty hardware, which makes a fresh public install a
|
||||
rover-only server until the operator opts into extra devices.
|
||||
*/
|
||||
if (typeof value === 'boolean') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function asTrimmedString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getRoomCameraEntries(config) {
|
||||
const raw = config.roomCameras;
|
||||
/*
|
||||
The public config uses `{ enabled, cameras }` so the feature gate is obvious.
|
||||
Accepting the old array shape here keeps the rest of the server from needing
|
||||
to know which shape the local config file currently uses.
|
||||
*/
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && typeof raw === 'object' && Array.isArray(raw.cameras)) return raw.cameras;
|
||||
return [];
|
||||
}
|
||||
|
||||
function getConfiguredSocials(config) {
|
||||
/*
|
||||
Social links have an explicit feature switch. Entries under `links` are just
|
||||
available data; they do not enable the Links panel by existing.
|
||||
*/
|
||||
const links = config.socials && typeof config.socials === 'object' ? config.socials.links : [];
|
||||
return Array.isArray(links)
|
||||
? links.filter((entry) => asTrimmedString(entry?.url))
|
||||
: [];
|
||||
}
|
||||
|
||||
function buildFeatureFlags(config = loadConfig()) {
|
||||
const homeAssistantConfig = config.homeAssistant || {};
|
||||
const roomCameraConfig = config.roomCameras || {};
|
||||
const kinectConfig = config.kinect || {};
|
||||
const buttonBoxConfig = config.buttonBox || {};
|
||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||
const balanceBoardConfig = config.balanceBoard || {};
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const ptzCameraConfig = config.ptzCamera || {};
|
||||
const discordConfig = config.discord || {};
|
||||
const fleetReportsConfig = config.fleetReports || {};
|
||||
const homeAssistant = Boolean(
|
||||
asBoolean(homeAssistantConfig.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.url) &&
|
||||
asTrimmedString(homeAssistantConfig.token),
|
||||
);
|
||||
const roomCameraEntries = getRoomCameraEntries(config);
|
||||
const roomCamerasEnabled = Array.isArray(config.roomCameras)
|
||||
? false
|
||||
: asBoolean(roomCameraConfig.enabled);
|
||||
const barcodeScanner = asBoolean(barcodeScannerConfig.enabled);
|
||||
|
||||
return {
|
||||
homeAssistant,
|
||||
roomCameras: Boolean(roomCamerasEnabled && roomCameraEntries.length),
|
||||
kinect: asBoolean(kinectConfig.enabled),
|
||||
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||
barcodeScanner,
|
||||
// The worker performs its own runtime availability reporting. Advertising
|
||||
// the feature from the explicit config switch lets the UI show useful
|
||||
// commissioning and hardware-error states even before a board is paired.
|
||||
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||
lift: Boolean(
|
||||
homeAssistant &&
|
||||
asBoolean(homeAssistantConfig.lift?.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.lift?.upSwitch) &&
|
||||
asTrimmedString(homeAssistantConfig.lift?.downSwitch),
|
||||
),
|
||||
neato: Boolean(
|
||||
homeAssistant &&
|
||||
asBoolean(homeAssistantConfig.neato?.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.neato?.device),
|
||||
),
|
||||
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
|
||||
interInstance: asBoolean(interInstanceConfig.enabled),
|
||||
ptzCamera: Boolean(
|
||||
asBoolean(ptzCameraConfig.enabled) &&
|
||||
asTrimmedString(ptzCameraConfig.host) &&
|
||||
asTrimmedString(ptzCameraConfig.username) &&
|
||||
asTrimmedString(ptzCameraConfig.password),
|
||||
),
|
||||
/*
|
||||
Discord is an optional transport, not a prerequisite for chat commands.
|
||||
Requiring both the explicit switch and a token prevents an old token from
|
||||
silently enabling external connections on installations that have chosen
|
||||
to run without the integration.
|
||||
*/
|
||||
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
||||
// Fleet reports are deliberately controlled by one explicit server switch.
|
||||
// Storage contents, Discord availability, or historical database files must
|
||||
// never cause the reporting UI to appear on an installation that has not
|
||||
// opted into the collector.
|
||||
fleetReports: asBoolean(fleetReportsConfig.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function getFeatureFlags() {
|
||||
return buildFeatureFlags(loadConfig());
|
||||
}
|
||||
|
||||
function isFeatureEnabled(featureName) {
|
||||
return Boolean(getFeatureFlags()[featureName]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildFeatureFlags,
|
||||
getFeatureFlags,
|
||||
isFeatureEnabled,
|
||||
getRoomCameraEntries,
|
||||
getConfiguredSocials,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Site Metadata Helper
|
||||
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
|
||||
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
const { loadConfig } = require('../configuration');
|
||||
|
||||
const DEFAULT_SITE_METADATA = Object.freeze({
|
||||
name: 'Multi Roomba Rover',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Administrative Configuration Service
|
||||
// Purpose: Exposes lockdown-only configuration, administrator, revision, and audit operations to the admin application.
|
||||
// Scope: Owns socket authorization and password confirmation while delegating persistence invariants to the configuration database.
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('adminConfigurationService');
|
||||
const {
|
||||
getConfigurationDatabase,
|
||||
getRuntimeConfigurationRevision,
|
||||
rootSchema,
|
||||
} = require('../../configuration');
|
||||
const { getRole } = require('../roleService');
|
||||
|
||||
const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
|
||||
const database = getConfigurationDatabase();
|
||||
|
||||
function requireLockdownAdministrator(socket) {
|
||||
if (getRole(socket) !== 'lockdown') throw new Error('Lockdown administrator required.');
|
||||
}
|
||||
|
||||
function requireRecentPassword(socket) {
|
||||
requireLockdownAdministrator(socket);
|
||||
const confirmedAt = Number(socket?.data?.adminPasswordConfirmedAt) || 0;
|
||||
if (Date.now() - confirmedAt > PASSWORD_CONFIRMATION_WINDOW_MS) {
|
||||
const error = new Error('Confirm your password to continue.');
|
||||
error.code = 'PASSWORD_CONFIRMATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function actorFor(socket) {
|
||||
return socket?.data?.user?.username || socket.id;
|
||||
}
|
||||
|
||||
function errorPayload(error) {
|
||||
return {
|
||||
error: error.message,
|
||||
code: error.code || null,
|
||||
validationErrors: error.validationErrors || null,
|
||||
currentRevision: error.currentRevision || null,
|
||||
};
|
||||
}
|
||||
|
||||
function ackHandler(socket, eventName, authorization, handler) {
|
||||
socket.on(eventName, (payload = {}, cb = () => {}) => {
|
||||
Promise.resolve()
|
||||
.then(() => authorization(socket))
|
||||
.then(() => handler(payload || {}))
|
||||
.then((result) => cb({ success: true, ...result }))
|
||||
.catch((error) => {
|
||||
logger.warn('Administrative configuration request failed', {
|
||||
eventName,
|
||||
socketId: socket.id,
|
||||
actor: actorFor(socket),
|
||||
error: error.message,
|
||||
});
|
||||
cb(errorPayload(error));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildAdminSnapshot() {
|
||||
const configuration = database.getClientConfiguration();
|
||||
return {
|
||||
/*
|
||||
The protected admin response carries the same schema used by server-side
|
||||
Ajv validation. It contains structure and help metadata but never stored
|
||||
values, allowing the browser to render configuration without maintaining
|
||||
a second field definition.
|
||||
*/
|
||||
configuration: { ...configuration, schema: rootSchema },
|
||||
restartRequired: configuration.revision !== getRuntimeConfigurationRevision(),
|
||||
administrators: database.listAdministrators(),
|
||||
revisions: database.listConfigurationRevisions(),
|
||||
auditEvents: database.listAuditEvents(),
|
||||
};
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
ackHandler(socket, 'adminConfig:get', requireLockdownAdministrator, () => buildAdminSnapshot());
|
||||
|
||||
ackHandler(socket, 'adminConfig:confirmPassword', requireLockdownAdministrator, async ({ password }) => {
|
||||
const admin = database.findAdministratorForAuthentication(socket?.data?.user?.username);
|
||||
if (!admin || !(await bcrypt.compare(String(password || ''), admin.passwordHash))) {
|
||||
throw new Error('Invalid credentials.');
|
||||
}
|
||||
socket.data.adminPasswordConfirmedAt = Date.now();
|
||||
return { confirmedUntil: socket.data.adminPasswordConfirmedAt + PASSWORD_CONFIRMATION_WINDOW_MS };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:updateConfiguration', requireRecentPassword, (payload) => {
|
||||
const revision = database.updateConfiguration({
|
||||
value: payload.value,
|
||||
expectedRevision: payload.expectedRevision,
|
||||
secretOperations: payload.secretOperations,
|
||||
actor: actorFor(socket),
|
||||
});
|
||||
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, (payload) => {
|
||||
const revision = database.restoreConfigurationRevision({
|
||||
revision: payload.revision,
|
||||
expectedRevision: payload.expectedRevision,
|
||||
actor: actorFor(socket),
|
||||
});
|
||||
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:createAdministrator', requireRecentPassword, async (payload) => {
|
||||
const password = String(payload.password || '');
|
||||
if (password.length < 10) throw new Error('Administrator password must be at least 10 characters.');
|
||||
const administrator = database.createAdministrator({
|
||||
username: payload.username,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
discordId: payload.discordId,
|
||||
role: payload.role,
|
||||
}, actorFor(socket));
|
||||
return { administrator, snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:updateAdministrator', requireRecentPassword, async (payload) => {
|
||||
const authenticatedAdministrator = database.findAdministratorForAuthentication(socket?.data?.user?.username);
|
||||
const changes = {
|
||||
username: payload.username,
|
||||
discordId: payload.discordId,
|
||||
role: payload.role,
|
||||
};
|
||||
if (payload.password) {
|
||||
if (String(payload.password).length < 10) throw new Error('Administrator password must be at least 10 characters.');
|
||||
changes.passwordHash = await bcrypt.hash(String(payload.password), 12);
|
||||
}
|
||||
const administrator = database.updateAdministrator(payload.id, changes, actorFor(socket));
|
||||
if (authenticatedAdministrator?.id === administrator.id) {
|
||||
/*
|
||||
Keep the current authenticated identity aligned after a self-edit. If
|
||||
the username changed but the socket retained the old name, its next
|
||||
password confirmation could never find the account it just updated.
|
||||
*/
|
||||
socket.data.user = {
|
||||
...(socket.data.user || {}),
|
||||
username: administrator.username,
|
||||
discordId: administrator.discordId,
|
||||
};
|
||||
}
|
||||
return { administrator, snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:deleteAdministrator', requireRecentPassword, ({ id }) => {
|
||||
database.deleteAdministrator(id, actorFor(socket));
|
||||
return { snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
PASSWORD_CONFIRMATION_WINDOW_MS,
|
||||
requireLockdownAdministrator,
|
||||
};
|
||||
@@ -97,6 +97,12 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('help', ({ needsHelp }) => {
|
||||
// Entering HELP affects only future automatic placement. When HELP clears,
|
||||
// retry people who were waiting because every healthy rover was unavailable.
|
||||
if (!needsHelp) reassignWaiting();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
||||
if (action === 'removed') {
|
||||
/*
|
||||
@@ -238,7 +244,10 @@ function pickRover(socket, options = {}) {
|
||||
return null;
|
||||
}
|
||||
const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||
if (!rover || rover.locked) return false;
|
||||
// HELP removes a rover only from automatic placement. Existing drivers are
|
||||
// not displaced, and explicit requestControl calls retain their normal
|
||||
// access policy so a person can deliberately take control to rescue it.
|
||||
if (!rover || rover.locked || rover.needsHelp) return false;
|
||||
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
|
||||
if (!access.ok) return false;
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Audio-Forwarding Configuration
|
||||
// Purpose: Defines upload bounds and the ffmpeg publishing command inputs.
|
||||
// Scope: Contains configuration metadata only and never creates runtime FIFOs.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'audioForward',
|
||||
defaultValue: { enabled: true, ffmpegBin: 'ffmpeg', streamSuffix: '-fwd', maxUploadBytes: 8388608 },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Enables verified current drivers to publish microphone or uploaded audio to their assigned rover.' }),
|
||||
ffmpegBin: string({ title: 'ffmpeg executable', description: 'Executable name or path used to run the long-lived audio publishing and playback workers.', minLength: 1, maxLength: 500 }),
|
||||
streamSuffix: string({ description: 'Suffix appended to each rover ID to form its internal MediaMTX forwarded-audio stream path.', minLength: 1, maxLength: 80 }),
|
||||
maxUploadBytes: integer({ description: 'Maximum accepted size in bytes for one uploaded audio clip.', minimum: 262144, maximum: 1073741824 }),
|
||||
}, { title: 'Audio forwarding', description: 'Controls browser-to-rover audio publishing, temporary uploaded clips, and the ffmpeg workers that feed MediaMTX.', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
|
||||
};
|
||||
@@ -5,7 +5,8 @@ const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveRuntimePath } = require('../../helpers/dataPaths');
|
||||
const roverManager = require('../roverManager');
|
||||
const turnService = require('../turnService');
|
||||
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
|
||||
@@ -19,13 +20,22 @@ const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const audioForwardConfig = config.audioForward || {};
|
||||
const mediaConfig = config.media || {};
|
||||
const serviceEnabled = audioForwardConfig.enabled !== false;
|
||||
// Configuration defaults always provide this boolean. Treat only an explicit
|
||||
// true as enabled so no credential, path, or historical fallback can opt the
|
||||
// service in on the operator's behalf.
|
||||
const serviceEnabled = Boolean(audioForwardConfig.enabled);
|
||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Audio-Level Configuration
|
||||
// Purpose: Defines server base gains and the permitted personal adjustment range.
|
||||
// Scope: Exports only defaults and validation metadata.
|
||||
const { strictObject, number, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'audioLevels',
|
||||
defaultValue: { hornGain: 1, ttsGain: 1, forwardGain: 1, maxPersonalAdjustmentPercent: 50 },
|
||||
schema: strictObject({
|
||||
hornGain: number({ description: 'Server base multiplier for external horn playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
|
||||
ttsGain: number({ title: 'TTS gain', description: 'Server base multiplier for text-to-speech playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
|
||||
forwardGain: number({ description: 'Server base multiplier for browser-forwarded and uploaded audio, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
|
||||
maxPersonalAdjustmentPercent: integer({ description: 'Largest positive or negative percentage adjustment permitted for users granted personal audio controls; zero disables personal variation.', minimum: 0, maximum: 100 }),
|
||||
}, { title: 'Audio levels', description: 'Sets the initial server-owned playback gains and the allowed range for per-user adjustments.', required: ['hornGain', 'ttsGain', 'forwardGain', 'maxPersonalAdjustmentPercent'] }),
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const fs = require('fs');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isAdmin, roleEvents } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('authService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getConfigurationDatabase } = require('../../configuration');
|
||||
const { clearLockdownTimer } = require('../lockdownGuard');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { setRole } = require('../roleService');
|
||||
@@ -19,12 +19,11 @@ const {
|
||||
updateFeatureState,
|
||||
} = require('../identityService');
|
||||
|
||||
const config = loadConfig();
|
||||
const admins = config.admins || [];
|
||||
const configurationDatabase = getConfigurationDatabase();
|
||||
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||
|
||||
function findAdmin(username) {
|
||||
return admins.find((admin) => admin.username === username);
|
||||
return configurationDatabase.findAdministratorForAuthentication(username);
|
||||
}
|
||||
|
||||
async function authenticate(username, password) {
|
||||
@@ -32,7 +31,7 @@ async function authenticate(username, password) {
|
||||
if (!admin) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
const ok = await bcrypt.compare(password, admin.password_hash);
|
||||
const ok = await bcrypt.compare(password, admin.passwordHash);
|
||||
if (!ok) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
@@ -138,11 +137,14 @@ io.on('connection', (socket) => {
|
||||
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
||||
try {
|
||||
const admin = await authenticate(username, password);
|
||||
if (getMode() === MODES.LOCKDOWN && !admin.lockdown) {
|
||||
if (getMode() === MODES.LOCKDOWN && admin.role !== 'lockdown') {
|
||||
throw new Error('Lockdown admins only');
|
||||
}
|
||||
const role = admin.lockdown ? 'lockdown' : 'admin';
|
||||
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
||||
const role = admin.role;
|
||||
socket.data.user = { username: admin.username, discordId: admin.discordId };
|
||||
// A successful login is also recent proof of the account password. The
|
||||
// admin service expires this timestamp before allowing sensitive writes.
|
||||
socket.data.adminPasswordConfirmedAt = Date.now();
|
||||
setRole(socket, role);
|
||||
/*
|
||||
In admin-gated external spectator mode, logging in from /spectate is the
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Balance Board Configuration
|
||||
// Purpose: Defines optional hardware enablement and development simulation.
|
||||
// Scope: Contains configuration metadata only and never opens Bluetooth.
|
||||
const { strictObject, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'balanceBoard',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, simulate: false },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Starts the Wii Balance Board service and exposes its readings and controls after restart.' }),
|
||||
simulate: boolean({ description: 'Runs the native worker with generated cyclic sensor data instead of connecting to Bluetooth hardware.' }),
|
||||
}, {
|
||||
title: 'Balance Board',
|
||||
description: 'Optional Wii Balance Board input service with a development simulation mode.',
|
||||
required: ['enabled', 'simulate'],
|
||||
}),
|
||||
};
|
||||
@@ -7,16 +7,15 @@ const { promisify } = require('util');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('balanceBoardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { createBalanceBoardHardware } = require('./hardware');
|
||||
|
||||
const events = new EventEmitter();
|
||||
const enabled = isFeatureEnabled('balanceBoard');
|
||||
const rawConfig = loadConfig().balanceBoard || {};
|
||||
const enabled = Boolean(rawConfig.enabled);
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('balance-board.json');
|
||||
const FRAME_ROOM = 'balance-board-viewers';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Barcode Games Configuration
|
||||
// Purpose: Defines the optional barcode-games identity and presentation.
|
||||
// Scope: Contains configuration metadata only and does not initialize game state.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'barcodeGames',
|
||||
feature: true,
|
||||
// Disabled-by-default feature state is independent from its complete visual
|
||||
// identity, matching how the legacy YAML template represented this service.
|
||||
defaultValue: { enabled: false, botName: 'Barcode Games', profileImageUrl: 'https://example.com/barcode-games.png' },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Enables shared barcode-game voting, participation, scoring, and game-state publication.' }),
|
||||
botName: string({ description: 'Nickname used for barcode-game lifecycle messages posted into chat.', minLength: 1, maxLength: 80 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', description: 'Optional image URL displayed beside barcode-game chat messages; leave blank for no custom image.', examples: ['https://example.com/barcode-games.png'], maxLength: 2048 }),
|
||||
}, { title: 'Barcode games', description: 'Controls the multiplayer games driven by scans received from the barcode scanner service.', required: ['enabled', 'botName', 'profileImageUrl'] }),
|
||||
};
|
||||
@@ -5,8 +5,7 @@
|
||||
// remain thin IO surfaces that subscribe to state and send votes/scans.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeGameService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { sendSystemMessage } = require('../chatService');
|
||||
const { getActiveDrivers } = require('../turnService');
|
||||
@@ -31,8 +30,8 @@ const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
|
||||
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
||||
const config = loadConfig();
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const enabled = isFeatureEnabled('barcodeGames');
|
||||
const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games';
|
||||
const enabled = Boolean(barcodeGamesConfig.enabled);
|
||||
const botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
|
||||
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
|
||||
|
||||
function sendBarcodeGameChat(text) {
|
||||
@@ -1125,8 +1124,9 @@ function broadcastState() {
|
||||
if (enabled) {
|
||||
/*
|
||||
Barcode games are an optional layer on top of the physical scanner station.
|
||||
Keep sockets and scan subscriptions behind the feature gate so disabled
|
||||
installs do not run invisible game state.
|
||||
The game's own switch controls whether its sockets and subscriptions exist.
|
||||
Scanner availability is runtime state and must not silently override the
|
||||
operator's explicit choice to enable the game service.
|
||||
*/
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Barcode-Scanner Configuration
|
||||
// Purpose: Defines whether the optional physical barcode scanner is active.
|
||||
// Scope: Contains configuration metadata only and never initializes hardware.
|
||||
const { strictObject, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'barcodeScanner',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Registers barcode scanning, barcode administration, and scan-triggered server behavior after restart.' }),
|
||||
}, {
|
||||
title: 'Barcode scanner',
|
||||
description: 'Optional physical barcode scanning and barcode registry service.',
|
||||
required: ['enabled'],
|
||||
}),
|
||||
};
|
||||
@@ -4,8 +4,8 @@
|
||||
const fs = require('fs');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeScannerService');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
|
||||
@@ -15,7 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
|
||||
const RECENT_SCAN_LIMIT = 8;
|
||||
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
|
||||
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
|
||||
const enabled = isFeatureEnabled('barcodeScanner');
|
||||
const enabled = Boolean(loadConfig().barcodeScanner?.enabled);
|
||||
|
||||
let lastKnownGoodRegistry = null;
|
||||
let lastRegistryError = null;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Button-Box Configuration
|
||||
// Purpose: Defines whether the optional physical button box is active.
|
||||
// Scope: Contains configuration metadata only and never initializes hardware.
|
||||
const { strictObject, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'buttonBox',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Registers the physical button-box input route and enables its persistent button rewards and effects after restart.' }),
|
||||
}, {
|
||||
title: 'Button box',
|
||||
description: 'Optional physical button-box input and reward system.',
|
||||
required: ['enabled'],
|
||||
}),
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('buttonBoxService');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { getRewardById, listRewards } = require('../../rewards');
|
||||
@@ -30,7 +30,7 @@ const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
||||
const BUTTON_COUNT = 4;
|
||||
const STORE_VERSION = 1;
|
||||
const enabled = isFeatureEnabled('buttonBox');
|
||||
const enabled = Boolean(loadConfig().buttonBox?.enabled);
|
||||
|
||||
const store = createButtonBoxStore({
|
||||
logger,
|
||||
|
||||
@@ -13,7 +13,7 @@ const homeAssistantService = require('../homeAssistantService');
|
||||
const greenModeService = require('../greenModeService');
|
||||
const liftService = require('../liftService');
|
||||
const neatoService = require('../neatoService');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { isFeatureEnabled } = require('../../configuration');
|
||||
const {
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
@@ -32,7 +32,7 @@ const {
|
||||
} = require('../identityService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { createCommandHandlers } = require('../operatorCommandService');
|
||||
const { parseCommandText } = require('../operatorCommandService/config');
|
||||
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Discord Bot Configuration
|
||||
// Purpose: Defines the optional bot connection and its guild channel and role mappings.
|
||||
// Scope: Contains configuration metadata only and never logs in to Discord.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'discord',
|
||||
feature: true,
|
||||
// Channel and role IDs retain the fully populated legacy-template shape, but
|
||||
// the credential remains empty and the bot cannot start until explicitly enabled.
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
token: '',
|
||||
guildId: '123456789012345678',
|
||||
siteUrl: 'https://rover.example.com',
|
||||
channels: {
|
||||
general: '123456789012345678',
|
||||
announcements: '123456789012345678',
|
||||
adminAlerts: '123456789012345678',
|
||||
replay: '123456789012345678',
|
||||
humanAlerts: '123456789012345678',
|
||||
},
|
||||
roles: {
|
||||
stalkerPing: '123456789012345678',
|
||||
announcementPing: '123456789012345678',
|
||||
adminPing: '123456789012345678',
|
||||
humanAlertPing: '123456789012345678',
|
||||
},
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Logs the Discord bot in and enables commands, chat bridges, replay delivery, and configured announcements after restart.' }),
|
||||
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
|
||||
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
siteUrl: string({ title: 'Public site URL', description: 'Public base URL appended to announcement embeds and server-hosted replay links.', examples: ['https://rover.example.com'], maxLength: 2048 }),
|
||||
channels: strictObject({
|
||||
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
replay: string({ description: 'Channel ID used to upload generated replay videos when Discord replay delivery is available.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
humanAlerts: string({ description: 'Channel ID used for physical human-alert button notifications and captured images.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
}, {
|
||||
title: 'Channels',
|
||||
description: 'Discord channel IDs that route each category of bot output.',
|
||||
required: ['general', 'announcements', 'adminAlerts', 'replay', 'humanAlerts'],
|
||||
}),
|
||||
roles: strictObject({
|
||||
stalkerPing: string({ description: 'Role ID mentioned by the button-box stalker-ping reward in the general channel.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
announcementPing: string({ description: 'Role ID mentioned by configured user announcements.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
adminPing: string({ description: 'Role ID mentioned for important administrative rover, battery, and help alerts.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
humanAlertPing: string({ description: 'Role ID mentioned when the physical human-alert button is pressed.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
}, {
|
||||
title: 'Roles',
|
||||
description: 'Discord role IDs mentioned for specific notification categories.',
|
||||
required: ['stalkerPing', 'announcementPing', 'adminPing', 'humanAlertPing'],
|
||||
}),
|
||||
}, {
|
||||
title: 'Discord',
|
||||
description: 'Optional Discord bot credentials, public URL, and notification routing.',
|
||||
required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'],
|
||||
}),
|
||||
};
|
||||
@@ -27,7 +27,14 @@ function formatNumber(value, digits = 1) {
|
||||
function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetReportService, roverManager, sendToChannel }) {
|
||||
let timer = null;
|
||||
const reportConfig = fleetConfig?.discord || {};
|
||||
const enabled = fleetReportService?.enabled && reportConfig.enabled !== false;
|
||||
const enabled = Boolean(reportConfig.enabled);
|
||||
/*
|
||||
Keep the configured choice separate from runtime availability. An operator
|
||||
can enable Discord delivery while the parent fleet collector is unhealthy
|
||||
or disabled; that dependency prevents work but does not rewrite the meaning
|
||||
of this switch.
|
||||
*/
|
||||
const fleetReportsAvailable = Boolean(fleetReportService?.enabled);
|
||||
const channelId = discordConfig?.channels?.adminAlerts;
|
||||
const zone = String(reportConfig.timezone || 'America/New_York');
|
||||
const { hour, minute } = parseSendTime(reportConfig.sendAt);
|
||||
@@ -85,7 +92,7 @@ function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetRepo
|
||||
}
|
||||
|
||||
async function deliverPreviousDay() {
|
||||
if (!enabled || !channelId) return;
|
||||
if (!enabled || !fleetReportsAvailable || !channelId) return;
|
||||
const range = completedDayRange();
|
||||
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
|
||||
if (existing?.discordDeliveredAt) return;
|
||||
@@ -116,7 +123,7 @@ function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetRepo
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
if (!enabled || !channelId) return;
|
||||
if (!enabled || !fleetReportsAvailable || !channelId) return;
|
||||
const next = nextRunAt({ zone, hour, minute });
|
||||
const delay = Math.max(1000, next.toMillis() - Date.now());
|
||||
timer = setTimeout(async () => {
|
||||
|
||||
@@ -9,8 +9,7 @@ const {
|
||||
} = require('discord.js');
|
||||
const logger = require('../../globals/logger').child('discordBot');
|
||||
const io = require('../../globals/io');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig, getConfigurationDatabase, isFeatureEnabled } = require('../../configuration');
|
||||
const { parseCommandText } = require('../operatorCommandService/config');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoster, lockRover, rovers } = roverManager;
|
||||
@@ -82,15 +81,16 @@ const {
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
const enabled = isFeatureEnabled('discord');
|
||||
const enabled = Boolean(discordConfig.enabled);
|
||||
// These normalized command names mirror the command router. Bridge-channel
|
||||
// command replies are mirrored into web chat, so this entrypoint needs to know
|
||||
// the configured command names before it wraps message.reply.
|
||||
const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean));
|
||||
const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
|
||||
const configuredAdministrators = getConfigurationDatabase().listAdministrators();
|
||||
const adminIds = new Set(configuredAdministrators.map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
|
||||
const lockdownAdminIds = new Set(configuredAdministrators.filter((admin) => admin.role === 'lockdown').map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
|
||||
|
||||
if (!enabled) {
|
||||
logger.info('Discord feature disabled or missing required token');
|
||||
logger.info('Discord disabled by config');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbe
|
||||
|
||||
function createBusEventHandler(deps) {
|
||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
|
||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
|
||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||
@@ -85,6 +85,23 @@ function createBusEventHandler(deps) {
|
||||
case 'rover.dockGuard':
|
||||
announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` });
|
||||
break;
|
||||
case 'rover.helpNeeded':
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
pingRoleId: roles.adminPing || null,
|
||||
color: 0xef4444,
|
||||
title: 'Rover Needs Help',
|
||||
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'}: ${payload?.reason || 'a sustained rover fault was detected'}.`,
|
||||
});
|
||||
break;
|
||||
case 'rover.helpCleared':
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0x4caf50,
|
||||
title: 'Rover Help Cleared',
|
||||
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'} no longer needs help.`,
|
||||
});
|
||||
break;
|
||||
case 'battery.warn':
|
||||
announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xf0b651, content: buildBatteryCaption(type, rovers.get(payload?.roverId || 'unknown')), embeds: [buildBatteryStatusEmbed({ color: 0xf0b651, records: Array.from(rovers.values()) })] });
|
||||
break;
|
||||
|
||||
@@ -11,7 +11,12 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
|
||||
in-app navigation. The retired desktop composition is intentionally exposed
|
||||
at /old; the removed /newdrive route is intentionally absent.
|
||||
*/
|
||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
|
||||
/*
|
||||
Every top-level React application needs the same generated index document on
|
||||
a direct browser load. Keeping the setup and admin routes in this explicit
|
||||
allowlist prevents them from working only after client-side navigation.
|
||||
*/
|
||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin'], async (req, res) => {
|
||||
try {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Fleet-Report Configuration
|
||||
// Purpose: Defines collection, retention, battery integration, delivery, and privacy behavior.
|
||||
// Scope: Contains configuration metadata only and never opens the reporting database.
|
||||
const { strictObject, string, boolean, integer, number } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'fleetReports',
|
||||
feature: true,
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
retention: { detailedDays: 0, minuteSamplesDays: 0 },
|
||||
battery: { enabled: true, maximumIntegrationGapSeconds: 5, minimumCapacityTestDepthPercent: 60 },
|
||||
discord: { enabled: true, sendAt: '08:00', timezone: 'America/New_York' },
|
||||
privacy: { retainChatBodies: true },
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery after restart.' }),
|
||||
retention: strictObject({
|
||||
detailedDays: integer({ description: 'Days to retain detailed events, command observations, sessions, and other non-minute fleet records. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
minuteSamplesDays: integer({ description: 'Days to retain per-minute rover metric aggregates. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
}, {
|
||||
title: 'Retention',
|
||||
description: 'Automatic cleanup windows for the two classes of fleet-report records.',
|
||||
required: ['detailedDays', 'minuteSamplesDays'],
|
||||
}),
|
||||
battery: strictObject({
|
||||
enabled: boolean({ description: 'Collects high-frequency battery sensor readings and derives charging, discharge, energy, and capacity metrics.' }),
|
||||
maximumIntegrationGapSeconds: number({ description: 'Largest allowed gap in seconds between battery readings before energy integration treats the telemetry as discontinuous.', minimum: 0.1, maximum: 3600 }),
|
||||
minimumCapacityTestDepthPercent: number({ description: 'Minimum observed full-to-low discharge depth required before a continuous session qualifies as a high-confidence capacity test. Runtime enforces at least 10 percent.', minimum: 0, maximum: 100 }),
|
||||
}, {
|
||||
title: 'Battery',
|
||||
description: 'Battery telemetry collection and quality thresholds used by fleet reports.',
|
||||
required: ['enabled', 'maximumIntegrationGapSeconds', 'minimumCapacityTestDepthPercent'],
|
||||
}),
|
||||
discord: strictObject({
|
||||
enabled: boolean({ description: 'Sends one completed daily fleet report to the configured Discord administrative-alert channel.' }),
|
||||
sendAt: string({ description: 'Local time of day to send the daily report, written as 24-hour HH:mm.', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }),
|
||||
timezone: string({ description: 'IANA timezone used to interpret the delivery time and determine each completed report day.', minLength: 1, maxLength: 100 }),
|
||||
}, {
|
||||
title: 'Discord delivery',
|
||||
description: 'Schedule for sending completed daily fleet summaries through the Discord bot.',
|
||||
required: ['enabled', 'sendAt', 'timezone'],
|
||||
}),
|
||||
privacy: strictObject({
|
||||
retainChatBodies: boolean({ description: 'Reserved privacy preference. The current collector does not read this setting and preserves complete event payloads, including chat content, regardless of its value.' }),
|
||||
}, {
|
||||
title: 'Privacy',
|
||||
description: 'Privacy controls reserved for any future fleet-report collection of message content.',
|
||||
required: ['retainChatBodies'],
|
||||
}),
|
||||
}, {
|
||||
title: 'Fleet reports',
|
||||
description: 'Persistent fleet operations reporting, retention, battery analysis, delivery, and privacy preferences.',
|
||||
required: ['enabled', 'retention', 'battery', 'discord', 'privacy'],
|
||||
}),
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
// Fleet Report Service
|
||||
// Purpose: Composes optional passive collection, storage, analysis, retention, and read-only transport.
|
||||
// Scope: This is the sole feature boundary; disabled installations register no collectors, timers, database, or sockets.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const logger = require('../../globals/logger').child('fleetReportService');
|
||||
|
||||
if (!isFeatureEnabled('fleetReports')) {
|
||||
const config = loadConfig().fleetReports || {};
|
||||
|
||||
if (!config.enabled) {
|
||||
module.exports = {
|
||||
enabled: false,
|
||||
getDailyReport: () => null,
|
||||
@@ -20,7 +21,6 @@ if (!isFeatureEnabled('fleetReports')) {
|
||||
const { createReportBuilder } = require('./reportBuilder');
|
||||
const { registerSocketGateway } = require('./socketGateway');
|
||||
|
||||
const config = loadConfig().fleetReports || {};
|
||||
const batteryConfig = config.battery || {};
|
||||
const retentionConfig = config.retention || {};
|
||||
const maximumIntegrationGapMs = Math.max(
|
||||
@@ -31,7 +31,10 @@ if (!isFeatureEnabled('fleetReports')) {
|
||||
10,
|
||||
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
|
||||
);
|
||||
const batteryEnabled = batteryConfig.enabled !== false;
|
||||
// Battery collection follows its own explicit nested switch. Defaults are
|
||||
// supplied by the validated configuration document, so a missing value does
|
||||
// not need a compatibility fallback that could accidentally enable it.
|
||||
const batteryEnabled = Boolean(batteryConfig.enabled);
|
||||
const storage = createStorage({ logger });
|
||||
const collector = createCollector({
|
||||
storage,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Home Assistant Configuration
|
||||
// Purpose: Defines the shared Home Assistant connection and the Neato, lift, entity, and button mappings that use it.
|
||||
// Scope: Keeps this connected configuration tree together without initializing any integration service.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
const neato = require('../neatoService/configuration');
|
||||
const lift = require('../liftService/configuration');
|
||||
|
||||
module.exports = {
|
||||
key: 'homeAssistant',
|
||||
feature: true,
|
||||
// Retain the actual child definitions so generic configuration metadata can
|
||||
// discover their feature switches without repeating nested paths centrally.
|
||||
nestedDefinitions: [neato, lift],
|
||||
// Example entities and triggers are real initial document values, as they
|
||||
// were in the YAML template. Home Assistant stays inert until enabled and a
|
||||
// real secret is deliberately installed by the operator.
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
url: 'http://127.0.0.1:8123',
|
||||
token: '',
|
||||
[neato.key]: neato.defaultValue,
|
||||
[lift.key]: lift.defaultValue,
|
||||
entities: [
|
||||
{ id: 'light.lab_main', name: 'Lab Lights' },
|
||||
{ id: 'switch.dock_power', name: 'Dock Power' },
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
entityId: 'sensor.basement_rover_buttons_action',
|
||||
stateEquals: 'on',
|
||||
cooldownMs: 15000,
|
||||
action: 'humanAlert',
|
||||
},
|
||||
{
|
||||
entityId: 'sensor.basement_rover_buttons_action',
|
||||
stateEquals: 'double',
|
||||
cooldownMs: 2000,
|
||||
action: 'modeTurns',
|
||||
},
|
||||
{
|
||||
entityId: 'sensor.basement_rover_buttons_action',
|
||||
stateEquals: 'hold',
|
||||
cooldownMs: 2000,
|
||||
action: 'modeAdmin',
|
||||
},
|
||||
{
|
||||
entityId: 'sensor.basement_rover_buttons_action',
|
||||
stateEquals: 'toggle',
|
||||
cooldownMs: 1000,
|
||||
action: 'lightsLockToggle',
|
||||
},
|
||||
],
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls after restart.' }),
|
||||
url: string({ title: 'Server URL', description: 'Base URL of the Home Assistant server used for its REST and WebSocket APIs.', format: 'uri', maxLength: 2048 }),
|
||||
token: string({ title: 'Long-lived access token', description: 'Home Assistant long-lived access token used to authenticate every API request. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_LONG_LIVED_TOKEN'], writeOnly: true, maxLength: 20000 }),
|
||||
[neato.key]: neato.schema,
|
||||
[lift.key]: lift.schema,
|
||||
entities: {
|
||||
type: 'array',
|
||||
title: 'Room entities',
|
||||
description: 'Home Assistant lights and switches exposed to the room-light controls and button-box actions.',
|
||||
items: strictObject({
|
||||
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID, such as light.rover_room or switch.floor_lamp.', examples: ['light.lab_main'], minLength: 1, maxLength: 255 }),
|
||||
name: string({ description: 'Human-readable name shown for this entity in the rover UI.', examples: ['Lab Lights'], minLength: 1, maxLength: 120 }),
|
||||
type: string({ description: 'Control behavior to expose: lights receive brightness-aware commands, while switches receive simple on and off commands.', enum: ['light', 'switch'] }),
|
||||
}, {
|
||||
description: 'One Home Assistant entity that the rover server can display and control.',
|
||||
required: ['id', 'name'],
|
||||
}),
|
||||
},
|
||||
buttons: {
|
||||
type: 'array',
|
||||
title: 'Physical button mappings',
|
||||
description: 'Maps Home Assistant entity state changes to built-in rover-server actions.',
|
||||
items: strictObject({
|
||||
entityId: string({ title: 'Entity id', description: 'Home Assistant entity whose state changes are watched as button presses.', examples: ['sensor.basement_rover_buttons_action'], minLength: 1, maxLength: 255 }),
|
||||
stateEquals: string({ description: 'Exact Home Assistant state that must be reached before the action fires.', examples: ['on'], minLength: 1, maxLength: 255 }),
|
||||
cooldownMs: integer({ description: 'Minimum milliseconds between accepted activations of this mapping.', examples: [15000], minimum: 0, maximum: 86400000 }),
|
||||
action: string({ description: 'Built-in action to run: raise a human alert, switch to turns mode, switch to admin mode, or toggle the room-light lock.', enum: ['humanAlert', 'modeTurns', 'modeAdmin', 'lightsLockToggle'] }),
|
||||
}, {
|
||||
description: 'One watched Home Assistant state transition and the server action it triggers.',
|
||||
required: ['entityId', 'stateEquals', 'cooldownMs', 'action'],
|
||||
}),
|
||||
},
|
||||
}, {
|
||||
title: 'Home Assistant',
|
||||
description: 'Connection, controllable entity catalog, and hardware-trigger mappings for the shared Home Assistant integration.',
|
||||
required: ['enabled', 'url', 'token', 'neato', 'lift', 'entities', 'buttons'],
|
||||
}),
|
||||
};
|
||||
@@ -2,8 +2,7 @@
|
||||
// Purpose: Composes Home Assistant transport, runtime automation engine, and event/socket hooks.
|
||||
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
|
||||
const logger = require('../../globals/logger').child('homeAssistantService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { events } = require('./state');
|
||||
const { createRuntimeEngine } = require('./runtimeEngine');
|
||||
const { createTransport } = require('./transport');
|
||||
@@ -11,7 +10,7 @@ const { registerHomeAssistantHooks } = require('./hooks');
|
||||
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const enabled = isFeatureEnabled('homeAssistant');
|
||||
const enabled = Boolean(haConfig.enabled);
|
||||
|
||||
let callHomeAssistantServiceImpl = async () => {
|
||||
throw new Error('Home Assistant not connected');
|
||||
@@ -39,9 +38,9 @@ runtimeEngine.loadTriggerConfig();
|
||||
|
||||
if (enabled) {
|
||||
/*
|
||||
Loading the module should be harmless on rover-only installs. Only connect
|
||||
to Home Assistant when the central feature gate says the integration exists,
|
||||
so placeholder URLs/tokens in example config cannot start network traffic.
|
||||
Loading the module should be harmless on rover-only installs. The explicit
|
||||
service-owned switch alone decides whether connection should be attempted;
|
||||
missing credentials are then reported as a runtime connection failure.
|
||||
*/
|
||||
transport.connect();
|
||||
}
|
||||
|
||||
@@ -73,7 +73,10 @@ function createTransport(deps) {
|
||||
|
||||
async function connect() {
|
||||
if (!enabled) {
|
||||
logger.info('Home Assistant integration disabled; missing url/token in config');
|
||||
// Disabled and misconfigured are intentionally different states. The
|
||||
// explicit switch prevents connection attempts; missing credentials are
|
||||
// surfaced by buildAuth() as a runtime connection failure when enabled.
|
||||
logger.info('Home Assistant disabled by config');
|
||||
return;
|
||||
}
|
||||
if (runtime.connection) return;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Inter-Instance Configuration
|
||||
// Purpose: Defines directory participation and the public profile published to other instances.
|
||||
// Scope: Exports data-only defaults and schema without starting polling or networking.
|
||||
const { strictObject, string, boolean, integer, stringArray } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'interInstance',
|
||||
feature: true,
|
||||
// Optional behavior remains disabled, but a new configuration now starts
|
||||
// with the same complete, editable template that the former YAML supplied.
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
directoryUrls: ['https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json'],
|
||||
pollIntervalMs: 30000,
|
||||
requestTimeoutMs: 5000,
|
||||
profile: {
|
||||
publicUrl: 'https://rover.example.com',
|
||||
name: 'Example Rover Server',
|
||||
description: 'A short public description of this rover server.',
|
||||
color: '#38bdf8',
|
||||
},
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Publishes this server\'s public instance information and polls the configured directories for peer servers.' }),
|
||||
directoryUrls: stringArray({
|
||||
item: {
|
||||
description: 'Absolute URL returning an array of peer MultiRover instance entries.',
|
||||
examples: ['https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json'],
|
||||
format: 'uri',
|
||||
},
|
||||
array: { description: 'Directory endpoints polled to discover other public MultiRover servers.' },
|
||||
}),
|
||||
pollIntervalMs: integer({ description: 'Milliseconds between peer-directory refreshes.', minimum: 1000, maximum: 86400000 }),
|
||||
requestTimeoutMs: integer({ description: 'Maximum milliseconds allowed for each directory or peer information request before it is aborted.', minimum: 250, maximum: 120000 }),
|
||||
profile: strictObject({
|
||||
publicUrl: string({ description: 'Public base URL peers and users use to reach this server; it also identifies and filters this instance from directory results.', examples: ['https://rover.example.com'], maxLength: 2048 }),
|
||||
name: string({ description: 'Public instance name advertised to peer servers.', minLength: 1, maxLength: 120 }),
|
||||
description: string({ description: 'Short public summary advertised with this instance.', examples: ['A short public description of this rover server.'], maxLength: 500 }),
|
||||
color: string({ description: 'Six-digit hexadecimal accent color advertised for this instance.', pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { description: 'Public identity this server publishes through the inter-instance information endpoint.', required: ['publicUrl', 'name', 'description', 'color'] }),
|
||||
}, { title: 'Inter-instance directory', description: 'Controls discovery and public information exchange between independent MultiRover servers.', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
|
||||
};
|
||||
@@ -6,8 +6,8 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('interInstanceService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getFeatureFlags, getConfiguredSocials } = require('../../helpers/features');
|
||||
const { loadConfig, getFeatureFlags } = require('../../configuration');
|
||||
const { getConfiguredSocials } = require('../sessionService/configuration');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getTurnQueues } = require('../turnService');
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Kinect Configuration
|
||||
// Purpose: Defines optional Kinect capture and cooldown behavior.
|
||||
// Scope: Contains configuration metadata only and never opens the native worker.
|
||||
const { strictObject, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'kinect',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, captureCooldownMs: 10000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Starts the Kinect worker and exposes authorized frame capture after restart.' }),
|
||||
captureCooldownMs: integer({ description: 'Minimum milliseconds between accepted Kinect frame-capture requests across all clients.', minimum: 0, maximum: 3600000 }),
|
||||
}, {
|
||||
title: 'Kinect',
|
||||
description: 'Optional Kinect frame capture and its server-wide request cooldown.',
|
||||
required: ['enabled', 'captureCooldownMs'],
|
||||
}),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Kinect Service
|
||||
// Purpose: Composes Kinect hardware capture and browser socket delivery.
|
||||
// Scope: Exposes session-readable state while keeping startup side effects in this service folder.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const hardware = require('./hardware');
|
||||
const { registerKinectSocketGateway, kinectEvents } = require('./socketGateway');
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Lift Configuration
|
||||
// Purpose: Defines lift switch mappings and command timing nested beneath Home Assistant.
|
||||
// Scope: Exports a nested configuration fragment without initializing either service.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'lift',
|
||||
feature: true,
|
||||
// These inert example entity IDs preserve the complete former YAML shape;
|
||||
// the explicit feature switch remains the only activation signal.
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
upSwitch: 'switch.lift_up',
|
||||
downSwitch: 'switch.lift_down',
|
||||
interlockMs: 2000,
|
||||
commandCooldownMs: 3000,
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Enables lift status and commands through the two configured Home Assistant switches after restart.' }),
|
||||
upSwitch: string({ description: 'Home Assistant switch entity that powers upward lift movement.', examples: ['switch.lift_up'], maxLength: 255 }),
|
||||
downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', examples: ['switch.lift_down'], maxLength: 255 }),
|
||||
interlockMs: integer({ description: 'Milliseconds to wait after turning off the opposing direction before energizing the requested direction. Runtime always enforces at least 250 ms.', minimum: 0, maximum: 600000 }),
|
||||
commandCooldownMs: integer({ description: 'Minimum milliseconds between lift commands. Runtime never allows this to be shorter than the interlock delay.', minimum: 0, maximum: 600000 }),
|
||||
}, {
|
||||
title: 'Lift',
|
||||
description: 'Bidirectional lift control using interlocked Home Assistant switch entities.',
|
||||
required: ['enabled', 'upSwitch', 'downSwitch', 'interlockMs', 'commandCooldownMs'],
|
||||
}),
|
||||
};
|
||||
@@ -4,8 +4,7 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('liftService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
const {
|
||||
@@ -20,7 +19,7 @@ const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const liftConfig = haConfig.lift || {};
|
||||
const featureEnabled = isFeatureEnabled('lift');
|
||||
const featureEnabled = Boolean(liftConfig.enabled);
|
||||
|
||||
const upSwitchId = String(liftConfig.upSwitch || '').trim();
|
||||
const downSwitchId = String(liftConfig.downSwitch || '').trim();
|
||||
@@ -73,7 +72,7 @@ function getState() {
|
||||
const configured = isConfigured();
|
||||
const connected = isHomeAssistantConnected();
|
||||
return {
|
||||
enabled: Boolean(featureEnabled && homeAssistantEnabled && configured),
|
||||
enabled: featureEnabled,
|
||||
configured,
|
||||
connected,
|
||||
entities: {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// LLM Commentary Configuration
|
||||
// Purpose: Defines the optional commentary model, endpoint, and cadence.
|
||||
// Scope: Contains configuration metadata only and never connects to Ollama.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'llmCommentary',
|
||||
defaultValue: { enabled: false, model: 'qwen2.5:7b-instruct', ollamaServer: 'http://127.0.0.1:11434', frequency: 120000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Starts periodic AI commentary generation from current rover, user, and chat activity.' }),
|
||||
model: string({ description: 'Ollama model name used to generate commentary.', minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', description: 'Base URL of the Ollama API used for commentary generation.', format: 'uri', maxLength: 2048 }),
|
||||
frequency: integer({ description: 'Commentary interval in milliseconds.', minimum: 1000, maximum: 86400000 }),
|
||||
}, { title: 'LLM commentary', description: 'Generates periodic AI-authored chat commentary from recent server activity through Ollama.', required: ['enabled', 'model', 'ollamaServer', 'frequency'] }),
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const fsp = require('fs/promises');
|
||||
const { Ollama } = require('ollama');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('llmCommentary');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
@@ -37,10 +37,10 @@ const { createRunner } = require('./runner');
|
||||
const config = loadConfig();
|
||||
const commentaryConfig = config.llmCommentary || {};
|
||||
const enabled = Boolean(commentaryConfig.enabled);
|
||||
const ollamaUrl = String(commentaryConfig.ollamaUrl || commentaryConfig.ollamaServer || '').trim();
|
||||
const ollamaUrl = String(commentaryConfig.ollamaServer || '').trim();
|
||||
const model = String(commentaryConfig.model || '').trim();
|
||||
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency ?? commentaryConfig.frequencyMs));
|
||||
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency));
|
||||
|
||||
const runtime = {
|
||||
timer: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// MediaMTX Config Builder
|
||||
// Purpose: Converts the rover server's media settings into the complete MediaMTX runtime configuration.
|
||||
// Scope: Keeps deployment-specific hosts in config.yaml while keeping protocol policy owned by the application.
|
||||
// Scope: Keeps deployment-specific hosts in the configuration database while protocol policy remains application-owned.
|
||||
const path = require('path');
|
||||
|
||||
function normalizeAdditionalHosts(rawHosts) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Media Transport Configuration
|
||||
// Purpose: Defines browser WHEP addressing and additional MediaMTX ICE hosts.
|
||||
// Scope: Contains configuration metadata only and never starts MediaMTX.
|
||||
const { strictObject, string, stringArray } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'media',
|
||||
defaultValue: {
|
||||
// Signaling remains server-local because the internal `/video` proxy owns
|
||||
// browser access; only ICE transport addresses come from the legacy sample.
|
||||
whepBaseUrl: 'http://127.0.0.1:8889/video',
|
||||
additionalHosts: ['rover.example.com', 'media-server.local'],
|
||||
},
|
||||
schema: strictObject({
|
||||
whepBaseUrl: string({ title: 'WHEP base URL', description: 'Base HTTP URL used to build browser WHEP playback and WHIP audio-publishing endpoints.', format: 'uri', maxLength: 2048 }),
|
||||
additionalHosts: stringArray({
|
||||
title: 'Additional ICE hosts',
|
||||
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
|
||||
array: { description: 'Additional public or LAN hostnames and addresses browsers may use to reach MediaMTX WebRTC transport.', uniqueItems: true },
|
||||
}),
|
||||
}, { title: 'Media', description: 'Controls browser signaling addresses and WebRTC network candidates generated for the managed MediaMTX process.', required: ['whepBaseUrl', 'additionalHosts'] }),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// MediaMTX Service
|
||||
// Purpose: Composes server configuration, runtime paths, and child-process supervision.
|
||||
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const globalConfig = require('../../globals/config');
|
||||
const logger = require('../../globals/logger').child('mediamtx');
|
||||
const { createMediaMtxSupervisor } = require('./supervisor');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// Neato Configuration
|
||||
// Purpose: Defines the Neato device mapping nested beneath the shared Home Assistant connection.
|
||||
// Scope: Exports a nested configuration fragment without initializing either service.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'neato',
|
||||
feature: true,
|
||||
// Keeping the example device in the saved template explains the required
|
||||
// ESPHome naming shape while `enabled: false` prevents accidental control.
|
||||
defaultValue: { enabled: false, device: 'neato_vacuum' },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Exposes Neato status and commands through the configured Home Assistant ESPHome device after restart.' }),
|
||||
device: string({ description: 'ESPHome device name used to derive the Neato entity IDs in Home Assistant; punctuation is normalized to underscores.', examples: ['neato_vacuum'], maxLength: 255 }),
|
||||
}, {
|
||||
title: 'Neato',
|
||||
description: 'Optional Neato robot controls backed by entities published from one ESPHome device through Home Assistant.',
|
||||
required: ['enabled', 'device'],
|
||||
}),
|
||||
};
|
||||
@@ -4,8 +4,7 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('neatoService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
@@ -22,7 +21,7 @@ const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const neatoConfig = haConfig.neato || {};
|
||||
const featureEnabled = isFeatureEnabled('neato');
|
||||
const featureEnabled = Boolean(neatoConfig.enabled);
|
||||
|
||||
function normalizeDeviceName(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
@@ -170,7 +169,7 @@ function buildState() {
|
||||
const requiredIds = requiredEntityIds();
|
||||
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
|
||||
const connected = Boolean(haConnected && entitiesAvailable);
|
||||
const enabled = Boolean(featureEnabled && homeAssistantEnabled && configured);
|
||||
const enabled = featureEnabled;
|
||||
|
||||
const controls = {
|
||||
start: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Operator Command Configuration
|
||||
// Purpose: Owns transport-neutral command names used by site chat and optional integrations.
|
||||
// Scope: Prevents Discord configuration from defining whether core server commands can be parsed.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
|
||||
function getCommandConfig(config = loadConfig()) {
|
||||
const commandConfig = config.commands || {};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Operator Command Configuration
|
||||
// Purpose: Defines the shared command prefix and optional bare time-status command.
|
||||
// Scope: Contains configuration metadata only and never builds command handlers.
|
||||
const { strictObject, string, nullableString } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'commands',
|
||||
defaultValue: { prefix: 'rs', timeStatusCommand: 'ts' },
|
||||
schema: strictObject({
|
||||
prefix: string({ description: 'Text placed before operator commands in web chat and Discord, such as rs help.', minLength: 1, maxLength: 20 }),
|
||||
timeStatusCommand: nullableString({ description: 'Optional command accepted without the normal prefix for the current time and rover status. Leave empty to disable the shortcut.', maxLength: 20 }),
|
||||
}, {
|
||||
title: 'Commands',
|
||||
description: 'Shared text syntax used by operator commands across web chat and Discord.',
|
||||
required: ['prefix', 'timeStatusCommand'],
|
||||
}),
|
||||
};
|
||||
@@ -102,7 +102,7 @@ function createCommandHandlers(deps) {
|
||||
const mode = getMode();
|
||||
const commandDefinition = registry[action];
|
||||
if (commandDefinition?.requiredFeature && !deps.isFeatureEnabled(commandDefinition.requiredFeature)) {
|
||||
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is not configured.` });
|
||||
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is disabled.` });
|
||||
return;
|
||||
}
|
||||
// Actions in this set can change operational safety or access policy, so
|
||||
|
||||
@@ -127,7 +127,7 @@ test('status and help survive lockdown', async () => {
|
||||
|
||||
test('a disabled required feature is reported before any permission check', async () => {
|
||||
const run = createRouter({ featureEnabled: false });
|
||||
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/);
|
||||
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is disabled/);
|
||||
});
|
||||
|
||||
test('green mode remains available without optional Home Assistant features', async () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Overseer Control Configuration
|
||||
// Purpose: Defines optional Overseer behavior and its model connection.
|
||||
// Scope: Contains declarative configuration only and never starts an Overseer loop.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'overseerControl',
|
||||
// The integration is still opt-in; populated presentation values keep its
|
||||
// initial document complete without causing the model loop to start.
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
mode: 'autonomous',
|
||||
observeOnly: true,
|
||||
postToolsOnlyMessages: false,
|
||||
tiebreakerEnable: false,
|
||||
runWhileNoPeopleOnline: false,
|
||||
name: 'The Overseer',
|
||||
model: 'qwen2.5:7b-instruct',
|
||||
ollamaServer: 'http://127.0.0.1:11434',
|
||||
profileImageUrl: 'https://example.com/overseer.png',
|
||||
gateIntervalMs: 2000,
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Enables the AI Overseer and its configured autonomous or direct-address execution path.' }),
|
||||
mode: string({ description: 'autonomous runs repeatedly when the user vote permits it; directAddress runs only when chat begins with the configured Overseer name.', enum: ['autonomous', 'directAddress'] }),
|
||||
observeOnly: boolean({ description: 'Lets the model evaluate state without executing tools or posting its generated chat response.' }),
|
||||
postToolsOnlyMessages: boolean({ description: 'Posts a chat feed entry for executed tool calls even when the model did not also produce chat text.' }),
|
||||
tiebreakerEnable: boolean({ description: 'Allows autonomous execution when eligible users are evenly split between enabling and disabling the Overseer.' }),
|
||||
runWhileNoPeopleOnline: boolean({ description: 'Allows autonomous execution when no eligible users are online to vote.' }),
|
||||
name: string({ description: 'Chat identity for Overseer messages and the phrase that triggers direct-address mode.', minLength: 1, maxLength: 80 }),
|
||||
model: string({ description: 'Ollama model name used for Overseer decisions.', minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', description: 'Base URL of the Ollama API used for Overseer decisions.', format: 'uri', maxLength: 2048 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', description: 'Optional image URL displayed beside Overseer chat messages; leave blank for no custom image.', examples: ['https://example.com/overseer.png'], maxLength: 2048 }),
|
||||
gateIntervalMs: integer({ description: 'Milliseconds waited after a completed autonomous decision before evaluating the next one.', minimum: 250, maximum: 3600000 }),
|
||||
}, { title: 'Overseer Control', description: 'Controls the AI agent that observes server state, optionally executes approved tools, and can speak in chat.', required: ['enabled', 'mode', 'observeOnly', 'postToolsOnlyMessages', 'tiebreakerEnable', 'runWhileNoPeopleOnline', 'name', 'model', 'ollamaServer', 'profileImageUrl', 'gateIntervalMs'] }),
|
||||
};
|
||||
@@ -2,7 +2,7 @@ const fsp = require('fs/promises');
|
||||
const { Ollama } = require('ollama');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('overseerControl');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { verificationEvents } = require('../verificationService');
|
||||
@@ -44,7 +44,7 @@ const runMode = RUN_MODES.has(configuredRunMode) ? configuredRunMode : RUN_MODE_
|
||||
const autonomousMode = runMode === RUN_MODE_AUTONOMOUS;
|
||||
const directAddressMode = runMode === RUN_MODE_DIRECT_ADDRESS;
|
||||
const model = String(overseerConfig.model || '').trim();
|
||||
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||
const ollamaUrl = String(overseerConfig.ollamaServer || '').trim();
|
||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
||||
const tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
|
||||
|
||||
@@ -118,7 +118,7 @@ function createPtzAudioPlayback(deps) {
|
||||
|
||||
/*
|
||||
Write on every playback instead of trying to detect config drift. The file
|
||||
is small, and this guarantees a camera password/host change in config.yaml
|
||||
is small, and this guarantees a camera password/host configuration change
|
||||
is reflected without an extra migration path or manual cleanup.
|
||||
*/
|
||||
await fsp.writeFile(configPath, body, { mode: 0o600 });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// PTZ Camera Configuration
|
||||
// Purpose: Defines the optional ONVIF camera connection and replay behavior.
|
||||
// Scope: Contains data-only metadata so validation never initializes camera hardware.
|
||||
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'ptzCamera',
|
||||
feature: true,
|
||||
// Non-secret commissioning values mirror the legacy template. The password
|
||||
// remains empty and `enabled: false` prevents an accidental camera login.
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
name: 'PTZ Camera',
|
||||
color: '#38bdf8',
|
||||
host: '192.168.0.8',
|
||||
onvifPort: 8000,
|
||||
username: 'admin',
|
||||
password: '',
|
||||
profileToken: '003',
|
||||
turnDurationMs: 300000,
|
||||
replayEnabled: false,
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Connects to the configured ONVIF camera and exposes its controls after restart.' }),
|
||||
name: string({ description: 'Human-readable camera name shown in the control interface.', minLength: 1, maxLength: 120 }),
|
||||
color: string({ description: 'Six-digit hexadecimal accent color used to identify this camera in the UI.', pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
host: string({ description: 'Hostname or IP address of the ONVIF camera.', examples: ['192.168.0.8'], maxLength: 255 }),
|
||||
onvifPort: integer({ title: 'ONVIF port', description: 'TCP port used for ONVIF control requests.', minimum: 1, maximum: 65535 }),
|
||||
username: string({ description: 'Camera account username used for ONVIF authentication.', examples: ['admin'], maxLength: 255 }),
|
||||
password: string({ description: 'Camera account password used for ONVIF authentication. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_CAMERA_PASSWORD'], writeOnly: true, maxLength: 10000 }),
|
||||
profileToken: string({ description: 'ONVIF media profile token used for stream discovery, presets, status, and movement commands.', maxLength: 255 }),
|
||||
turnDurationMs: integer({ description: 'Milliseconds assigned to each queued user turn controlling the PTZ camera.', minimum: 1000, maximum: 86400000 }),
|
||||
replayEnabled: boolean({ description: 'Allows this camera to appear as an available replay source.' }),
|
||||
}, {
|
||||
title: 'PTZ camera',
|
||||
description: 'Optional ONVIF pan-tilt-zoom camera connection, presentation, turn timing, and replay availability.',
|
||||
required: ['enabled', 'name', 'color', 'host', 'onvifPort', 'username', 'password', 'profileToken', 'turnDurationMs', 'replayEnabled'],
|
||||
}),
|
||||
};
|
||||
@@ -9,8 +9,8 @@ const { Cam } = require('onvif');
|
||||
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('ptzCamera');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||
@@ -43,7 +43,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;
|
||||
@@ -53,7 +53,7 @@ const PUBLISHER_RTSP_TIMEOUT_US = 10000000;
|
||||
const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const cameraConfig = config.ptzCamera || {};
|
||||
const enabled = isFeatureEnabled('ptzCamera');
|
||||
const enabled = Boolean(cameraConfig.enabled);
|
||||
|
||||
const state = {
|
||||
initialized: false,
|
||||
@@ -1057,8 +1057,8 @@ function requireOperator(socket) {
|
||||
function requirePtzUser(socket) {
|
||||
/*
|
||||
Listing presets does not move the camera, but it still reveals operational
|
||||
camera state. Use the same feature gate as queue entry so unverified users
|
||||
cannot query PTZ-only data through raw socket calls.
|
||||
camera state. Check the camera's own enabled switch just like queue entry so
|
||||
unverified users cannot query PTZ-only data through raw socket calls.
|
||||
*/
|
||||
if (!enabled) throw new Error('PTZ camera disabled');
|
||||
if (!canUsePtzFeature(socket)) throw new Error('Not authorized for PTZ camera');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// Scope: Owns camera identity/url normalization and read-only accessors for room camera metadata.
|
||||
const EventEmitter = require('events');
|
||||
const logger = require('../../globals/logger').child('roomCameraService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getRoomCameraEntries } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
|
||||
const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
@@ -17,7 +16,7 @@ function normalizeCamera(camera) {
|
||||
logger.warn('Room camera missing id', camera);
|
||||
return null;
|
||||
}
|
||||
if (!camera.url && !camera.streamUrl && !camera.mjpegUrl) {
|
||||
if (!camera.url && !camera.streamUrl) {
|
||||
logger.warn('Room camera missing url/streamUrl', { id, camera });
|
||||
return null;
|
||||
}
|
||||
@@ -26,7 +25,7 @@ function normalizeCamera(camera) {
|
||||
name: camera.name || camera.id || String(id),
|
||||
description: camera.description || null,
|
||||
url: camera.url || null,
|
||||
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
|
||||
streamUrl: camera.streamUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +40,9 @@ function getRoomCamera(id) {
|
||||
|
||||
function loadFromConfig() {
|
||||
cameraMap.clear();
|
||||
const list = getRoomCameraEntries(config);
|
||||
// Schema validation guarantees the configured list shape. Keeping its
|
||||
// fallback local makes the camera catalog independent of feature projection.
|
||||
const list = Array.isArray(config.roomCameras?.cameras) ? config.roomCameras.cameras : [];
|
||||
list.forEach((camera) => {
|
||||
const normalized = normalizeCamera(camera);
|
||||
if (normalized) cameraMap.set(normalized.id, normalized);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Room-Camera Configuration
|
||||
// Purpose: Defines the optional named snapshot and stream camera catalog.
|
||||
// Scope: Contains configuration metadata only and never contacts a camera.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'roomCameras',
|
||||
feature: true,
|
||||
// The example catalog documents the complete repeated-item shape as actual
|
||||
// initial configuration while the feature switch prevents network requests.
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
cameras: [
|
||||
{
|
||||
id: 'lobby',
|
||||
name: 'Lobby Camera',
|
||||
description: 'Wide shot of the staging area.',
|
||||
url: 'http://192.168.0.50/snapshot.jpg',
|
||||
streamUrl: 'http://192.168.0.50/stream.mjpg',
|
||||
},
|
||||
{
|
||||
id: 'workshop',
|
||||
name: 'Workshop Bench',
|
||||
description: 'Shows the workbench and charging docks.',
|
||||
url: 'http://192.168.0.51/snapshot.jpg',
|
||||
streamUrl: 'http://192.168.0.51/stream.mjpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Publishes the configured room-camera catalog and enables camera snapshots and streams after restart.' }),
|
||||
cameras: {
|
||||
type: 'array',
|
||||
description: 'Room cameras available to the web UI and replay system.',
|
||||
items: strictObject({
|
||||
id: string({ description: 'Stable camera identifier used in socket requests, selections, and replay source names.', examples: ['lobby'], minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
name: string({ description: 'Human-readable camera name shown in the UI.', examples: ['Lobby Camera'], minLength: 1, maxLength: 120 }),
|
||||
description: string({ description: 'Optional explanation of the camera location or view shown in the UI.', examples: ['Wide shot of the staging area.'], maxLength: 500 }),
|
||||
url: string({ title: 'Snapshot URL', description: 'Optional HTTP URL fetched when the server needs a still image from this camera.', examples: ['http://192.168.0.50/snapshot.jpg'], format: 'uri', maxLength: 2048 }),
|
||||
streamUrl: string({ title: 'Stream URL', description: 'Optional live stream URL consumed by the server snapshot engine and replay capture path.', examples: ['http://192.168.0.50/stream.mjpg'], maxLength: 2048 }),
|
||||
}, {
|
||||
// The runtime has always accepted snapshot-only and stream-only camera
|
||||
// entries, and treats descriptions as presentation metadata. Requiring
|
||||
// all three optional values made working YAML impossible to import.
|
||||
description: 'One named room camera with an optional description and any snapshot or live-stream sources it provides.',
|
||||
required: ['id', 'name'],
|
||||
}),
|
||||
},
|
||||
}, {
|
||||
title: 'Room cameras',
|
||||
description: 'Optional catalog of fixed cameras used for room views, server-produced snapshots, and replay sources.',
|
||||
required: ['enabled', 'cameras'],
|
||||
}),
|
||||
};
|
||||
@@ -5,9 +5,9 @@ const { loadFromConfig, getRoomCameras, getRoomCamera, roomCameraEvents } = requ
|
||||
const { createSnapshotEngine } = require('./snapshotEngine');
|
||||
const { registerRoomCameraSocketGateway } = require('./socketGateway');
|
||||
const replay = require('../replayEngineV2/roomCameraReplayBuilder');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { loadConfig } = require('../../configuration');
|
||||
|
||||
const enabled = isFeatureEnabled('roomCameras');
|
||||
const enabled = Boolean(loadConfig().roomCameras?.enabled);
|
||||
|
||||
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
|
||||
if (enabled) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user