Compare commits

...
59 Commits
Author SHA1 Message Date
legop3 17b1404157 converge everything into one data folder 2026-09-13 22:10:15 -04:00
legop3 b199f45eb2 add bars to thingy flingy 2026-09-13 15:43:41 -04:00
legop3 0f08fb3f0d Merge pull request #25 from legop3/newcontroller
Newcontroller
2026-09-13 02:09:38 -04:00
legop3 0f5a33c1de slop tank steering 2026-09-13 01:41:32 -04:00
legop3 8e96c3cdae glorp! 2026-09-12 20:52:34 -04:00
legop3 ba5c1c5d25 dont stop assignments for help rovers, just allow people to leave them. 2026-09-12 18:58:32 -04:00
legop3 6a914faffb Merge pull request #24 from legop3/HELP
Help
2026-09-12 18:34:35 -04:00
legop3 3b99590b3b lowe note one octave 2026-09-12 18:32:44 -04:00
legop3 5acbf6e0bf fix song hopefully? 2026-09-12 18:14:43 -04:00
legop3 3ec45de4b4 also beep roomba at the same time 2026-09-12 17:44:12 -04:00
legop3 3b7b2ac21e horn beep help thing yay 2026-09-12 17:17:18 -04:00
legop3 02a32e2524 fix help while docked lol 2026-09-12 17:04:21 -04:00
legop3 eb1fab50e4 adjust flashings 2026-09-12 16:43:36 -04:00
legop3 f85e258c29 adjust flashings 2026-09-12 16:41:23 -04:00
legop3 72b8db8a31 dont like shadow 2026-09-12 16:39:40 -04:00
legop3 fb31ff52bd HELP!!!! 2026-09-12 16:16:59 -04:00
legop3 99d2a7689f todoing 2026-09-12 15:23:04 -04:00
legop3 124369dfd7 forgot to pi build?? idk 2026-09-12 14:54:41 -04:00
legop3 4e24b5437d Merge pull request #23 from legop3/esp32io
Esp32io
2026-09-12 14:44:19 -04:00
legop3 a3c13f3dd3 fix title dark 2026-09-12 14:35:10 -04:00
legop3 b0389b5ddc ui tweak fling 2026-09-12 14:31:17 -04:00
legop3 9ca039229a slopping up a platformio library for people to make rover peripherals 2026-09-09 18:43:39 -04:00
legop3 8895ed6bd8 fixfix 2026-09-08 23:56:45 -04:00
legop3 6ca7cc0cf0 adjusting stylings 2026-09-08 23:52:25 -04:00
legop3 d3fd3946e6 uiuiui 2026-09-08 23:11:03 -04:00
legop3 038ae0f45a add consolenotifier for peripheral system alerts 2026-09-08 21:46:10 -04:00
legop3 3859806fca testings are going goods 2026-09-08 21:10:04 -04:00
legop3 f654394636 roverd tty alerts stuffs 2026-08-21 01:24:59 -04:00
legop3 c8836f7d65 rover server restart desync fix hopefully 2026-08-20 15:27:23 -04:00
legop3 f77a969098 loading screen, better HUD, etc. 2026-08-20 14:38:07 -04:00
legop3 5ab62c3633 bebahba 2026-08-19 21:35:02 -04:00
legop3 fab0673d9e many small improvements 2026-08-19 19:08:06 -04:00
legop3 a5ec1dcb2d fixing pod collapse expandings 2026-08-19 14:20:57 -04:00
legop3 6ff60f7d0e slop board 2026-08-19 00:10:41 -04:00
legop3 ea16e2c67a slop board 2026-08-18 23:56:26 -04:00
legop3 9615423e06 slop board 2026-08-18 23:51:19 -04:00
legop3 93232e2a54 balance board slopping 2026-08-18 23:36:38 -04:00
legop3 271197f33c ui race condition fix 2026-08-18 23:15:24 -04:00
legop3 8e7d31dcdd better dock resolving 2026-08-18 22:51:39 -04:00
legop3 4f87a0eec2 change theme gap back to smallers 2026-08-18 22:42:38 -04:00
legop3 cc2e85d174 assignment adjustments and ui tweakings 2026-08-18 22:36:42 -04:00
legop3 737760ff56 big boy webui new new new new new 100 files changed 80 years 2026-08-18 22:01:25 -04:00
legop3 0f0f82e5f6 newdrive planning 2026-08-17 02:54:01 -04:00
legop3 3807b8bb13 Merge branch 'main' of https://github.com/legop3/MultiRoombaRover 2026-08-14 23:22:28 -04:00
legop3 9f2f819bbd neato alerts and better commands 2026-08-14 23:22:27 -04:00
legop3 60329e1036 Enhance MIDI player task with detailed research points
Expanded on the first task to improve the MIDI player with detailed research points and objectives.
2026-08-14 23:11:04 -04:00
legop3 4430517af5 neato updates 2026-08-14 20:25:07 -04:00
legop3 b24d453ad1 redo and move rover ranking a little 2026-08-11 21:34:29 -04:00
legop3 b5e8d775a2 home assistant always turn lights on at full brightness trying to fix weird bulb 2026-08-11 21:25:16 -04:00
legop3 e5830533ba arm powered steam link ?? 2026-08-11 20:50:15 -04:00
legop3 dfd674a447 arm powered steam deck 2026-08-11 20:36:55 -04:00
legop3 bd65f93756 green adjustment 2026-08-09 00:31:11 -04:00
legop3 0083c887f4 fix 2026-08-09 00:13:38 -04:00
legop3 70f71b2d1e green mode slop 1 2026-08-08 23:59:47 -04:00
legop3 c8742dbbd6 slop planning 2026-08-08 00:20:08 -04:00
legop3 6a6dec5540 crocs 2026-08-07 23:25:56 -04:00
legop3 6c69c583c5 moving audio gain perms around 2026-08-07 22:34:04 -04:00
legop3 9702cf0f82 gruh i hate fun!! 2026-08-07 15:54:28 -04:00
legop3 a55257dd51 Merge pull request #22 from legop3/transportswap
Transportswap merge
2026-08-05 14:55:30 -04:00
272 changed files with 16114 additions and 4878 deletions
+1
View File
@@ -21,6 +21,7 @@ server/data/admin-reason.json
server/data/buttonbox-state.json
server/data/barcode-tts-cache/
server/data/rover-odometers.json
server/data/mediamtx.yml
webui/package-lock.json
!server/data/
!server/data/barcode-registry.json
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+134
View File
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
New Drive corner-pod HUD sketch, second design pass
Purpose: Shows true edge-mounted pods, physically attached expansions, and circular controls.
Scope: Static design communication only; production geometry remains an implementation decision.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900" role="img" aria-labelledby="title description">
<title id="title">Edge-mounted New Drive HUD pods</title>
<desc id="description">Four pods flow directly into the corners of a four by three rover video. Each has one inward rounded corner, and expansions attach directly along video edges.</desc>
<defs>
<linearGradient id="video" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#26343d" />
<stop offset="0.55" stop-color="#111827" />
<stop offset="1" stop-color="#1f2937" />
</linearGradient>
<linearGradient id="battery" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#22c55e" />
<stop offset="0.72" stop-color="#84cc16" />
<stop offset="1" stop-color="#eab308" />
</linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#000000" flood-opacity="0.5" />
</filter>
<style>
.pod { fill: #080a0f; fill-opacity: 0.86; stroke: #d1d5db; stroke-opacity: 0.26; stroke-width: 2; }
.expansion { fill: #080a0f; fill-opacity: 0.82; stroke: #d1d5db; stroke-opacity: 0.2; stroke-width: 2; }
.label { fill: #f8fafc; font-family: Inter, system-ui, sans-serif; font-weight: 700; }
.small { fill: #cbd5e1; font-family: Inter, system-ui, sans-serif; font-size: 17px; }
.tiny { fill: #94a3b8; font-family: Inter, system-ui, sans-serif; font-size: 14px; }
.arrow-button { fill: #1f2937; stroke: #e5e7eb; stroke-opacity: 0.55; stroke-width: 1.5; }
.arrow { fill: none; stroke: #f8fafc; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.track { fill: none; stroke: #334155; stroke-linecap: round; }
</style>
</defs>
<!-- The entire canvas is the shared 4:3 video/HUD coordinate space. -->
<rect width="1200" height="900" fill="url(#video)" />
<path d="M0 610 C235 510 390 590 600 515 C820 438 1000 520 1200 430 L1200 900 L0 900 Z" fill="#0b1516" opacity="0.76" />
<path d="M0 655 C240 555 425 635 630 560 C840 483 1020 560 1200 475" fill="none" stroke="#334155" stroke-width="5" opacity="0.42" />
<text x="600" y="450" text-anchor="middle" class="small" opacity="0.28">Rover video</text>
<!-- Top-left pod flows into the top and left edges; only its inward bottom-right corner rounds. -->
<g filter="url(#shadow)">
<path class="pod" d="M0 0 H190 V124 Q190 190 124 190 H0 Z" />
<circle class="track" cx="91" cy="91" r="55" stroke-width="14" />
<circle cx="91" cy="91" r="55" fill="none" stroke="#38bdf8" stroke-width="14" stroke-linecap="round" stroke-dasharray="255 346" transform="rotate(-90 91 91)" />
<text x="91" y="84" text-anchor="middle" class="tiny">Turn</text>
<text x="91" y="116" text-anchor="middle" class="label" font-size="30">0:42</text>
<circle class="arrow-button" cx="22" cy="22" r="16" />
<path class="arrow" d="M29 29 L16 16 M16 16 L25 16 M16 16 L16 25" />
<!-- This expansion begins exactly where the pod ends and continues directly into the top edge. -->
<path class="expansion" d="M190 0 H486 V50 Q486 78 458 78 H190 Z" />
<rect x="210" y="20" width="200" height="38" rx="8" fill="#7c3aed" opacity="0.72" />
<text x="310" y="45" text-anchor="middle" class="label" font-size="18">Rover name</text>
<circle class="arrow-button" cx="458" cy="39" r="14" />
<path class="arrow" d="M458 46 L458 32 M458 32 L452 38 M458 32 L464 38" />
</g>
<!-- Top-right pod and both expansions form one continuous edge-mounted cluster. -->
<g filter="url(#shadow)">
<path class="pod" d="M1010 0 H1200 V190 H1076 Q1010 190 1010 124 Z" />
<circle cx="1105" cy="91" r="59" fill="none" stroke="#334155" stroke-width="13" />
<circle cx="1105" cy="91" r="59" fill="none" stroke="url(#battery)" stroke-width="13" stroke-linecap="round" stroke-dasharray="300 371" transform="rotate(-90 1105 91)" />
<circle cx="1105" cy="91" r="42" fill="none" stroke="#334155" stroke-width="7" />
<circle cx="1105" cy="91" r="42" fill="none" stroke="#f59e0b" stroke-width="7" stroke-linecap="round" stroke-dasharray="112 264" transform="rotate(-90 1105 91)" />
<text x="1105" y="101" text-anchor="middle" class="label" font-size="30">81%</text>
<circle class="arrow-button" cx="1178" cy="22" r="16" />
<path class="arrow" d="M1171 29 L1184 16 M1184 16 L1175 16 M1184 16 L1184 25" />
<!-- Left expansion is attached to the pod at x=1010 and touches the top video edge. -->
<path class="expansion" d="M690 0 H1010 V78 H718 Q690 78 690 50 Z" />
<text x="718" y="31" class="label" font-size="18">Dock assist</text>
<text x="718" y="57" class="small">Dock rover</text>
<rect x="912" y="24" width="38" height="30" rx="6" fill="#312e81" stroke="#a5b4fc" />
<text x="931" y="45" text-anchor="middle" class="label" font-size="14">G</text>
<circle class="arrow-button" cx="980" cy="39" r="14" />
<path class="arrow" d="M980 46 L980 32 M980 32 L974 38 M980 32 L986 38" />
<!-- Lower expansion shares the pod's bottom edge and flows directly into the right edge. -->
<path class="expansion" d="M930 190 H1200 V420 H996 Q930 420 930 354 Z" />
<text x="958" y="225" class="label" font-size="18">Advanced power</text>
<text x="958" y="255" class="small">Voltage</text>
<rect x="958" y="266" width="214" height="8" rx="2" fill="#334155" />
<rect x="958" y="266" width="160" height="8" rx="2" fill="#38bdf8" />
<text x="958" y="306" class="small">Current</text>
<rect x="958" y="317" width="214" height="8" rx="2" fill="#334155" />
<rect x="958" y="317" width="90" height="8" rx="2" fill="#f59e0b" />
<text x="958" y="359" class="tiny">Computer 54 C</text>
<text x="958" y="383" class="tiny">Wi-Fi -58 dBm</text>
<circle class="arrow-button" cx="1174" cy="216" r="14" />
<path class="arrow" d="M1167 216 L1181 216 M1181 216 L1175 210 M1181 216 L1175 222" />
</g>
<!-- Bottom-left pod flows into the left and bottom edges with a compact triangular control group. -->
<g filter="url(#shadow)">
<path class="pod" d="M0 680 H220 Q300 680 300 760 V900 H0 Z" />
<circle cx="68" cy="758" r="38" fill="#172554" stroke="#60a5fa" stroke-width="2" />
<text x="68" y="754" text-anchor="middle" class="label" font-size="24"></text>
<text x="68" y="779" text-anchor="middle" class="tiny">E</text>
<circle cx="102" cy="850" r="38" fill="#3b2f0b" stroke="#facc15" stroke-width="2" />
<text x="102" y="846" text-anchor="middle" class="label" font-size="24"></text>
<text x="102" y="871" text-anchor="middle" class="tiny">R</text>
<circle cx="208" cy="798" r="57" fill="#3f1d2e" stroke="#fb7185" stroke-width="3" />
<text x="208" y="794" text-anchor="middle" class="label" font-size="25">Horn</text>
<text x="208" y="823" text-anchor="middle" class="tiny">H</text>
<circle class="arrow-button" cx="252" cy="754" r="14" />
<path class="arrow" d="M246 754 L258 754 M258 754 L253 749 M258 754 L253 759" />
<circle class="arrow-button" cx="22" cy="878" r="16" />
<path class="arrow" d="M29 871 L16 884 M16 884 L25 884 M16 884 L16 875" />
</g>
<!-- Bottom-right pod contains a circular tilt slider rather than a horizontal or pill track. -->
<g filter="url(#shadow)">
<path class="pod" d="M840 900 V760 Q840 680 920 680 H1200 V900 Z" />
<text x="1168" y="714" text-anchor="end" class="label" font-size="18">Camera tilt</text>
<circle class="track" cx="1030" cy="800" r="76" stroke-width="13" />
<circle cx="1030" cy="800" r="76" fill="none" stroke="#38bdf8" stroke-width="13" stroke-linecap="round" stroke-dasharray="285 478" transform="rotate(140 1030 800)" />
<circle cx="976" cy="746" r="13" fill="#e0f2fe" stroke="#0284c7" stroke-width="4" />
<text x="1030" y="808" text-anchor="middle" class="label" font-size="25">-12.5°</text>
<text x="1030" y="832" text-anchor="middle" class="tiny">Click for zero</text>
<circle cx="948" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
<text x="948" y="844" text-anchor="middle" class="label" font-size="14">J</text>
<circle cx="1112" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
<text x="1112" y="844" text-anchor="middle" class="label" font-size="14">U</text>
<circle class="arrow-button" cx="1178" cy="878" r="16" />
<path class="arrow" d="M1171 871 L1184 884 M1184 884 L1175 884 M1184 884 L1184 875" />
</g>
<!-- Immediate sensor overlays remain separate and are shown only as faint context here. -->
<path d="M360 900 Q600 808 840 900" fill="none" stroke="#ef4444" stroke-width="13" stroke-linecap="round" opacity="0.25" />
<path d="M410 886 Q600 820 790 886" fill="none" stroke="#22c55e" stroke-width="5" stroke-dasharray="12 10" opacity="0.4" />
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

+75
View File
@@ -0,0 +1,75 @@
- start work on new better ui layout, using components that already exist when possible
- centered rover video, full screen height
- rover HUD contains small but expandable rover telemetry UI and vis
- make newgen folder for new HUD elements. reuse old elements where possible
- make all new hud elements small and clean
- every hud element:
- is a nice small translucent thing with text icons or both
- can be expanded to show more relavent information
- is consistent. maybe make a reusable thing for this
- some specific hud elements:
- top bar:
- battery percentage that goes red and flashes and such
- turns hud that shows people in queue
- big in the middle
- left and right sides
- wheel drop indicators that show up when wheel drop is happening
- overcurrent and battery warnings
- bottom section:
- sensor elements that show up only when the sensor is "happening"
- bumpers
- front IR proximity sensors
- two sidebars
- sidebars contain all the stuff that isnt the rover
- left
- idk
- right
- chat, users, rovers list, and replay sources
- everything involving the rover is a video HUD, everything external is in the sidebars
## section 2
There will be corner mounted (one pod in each corner of the video), rounded pods in the HUD, which will contain gauges and controls for the rover
These pods will be collapsible, with a corner mounted arrow. the arrow points towards the corner when the pod is out, and points out of the corner when the pod is hidden.
There can also be "pod expansions" that will be in the corner of the pod and the side of the video. These are also collapsible, but they collapse into the side of the video that they are touching, instead of collapsing into the corner, with the same style arrow button as the pods.
For example, a pod in the top left is open. This pod has an expansion to it's right that is also open. I can collapse the pod into the corner, the expansion stays, it gets moved into the top left corner where the pod was.
- corner pods:
- top left
- pod
- turns timer
- round gauge circle that ticks down with time
- inside it, is the turn countdown
- this pod goes away when theres nothing to count
- right of pod expansion
- rover name with colored background
- expanded by default
- top right
- pod
- round rover battery bar gauge, based off how battery bar looks
- concentric to this bar is an unlabeled current gauge, styled after the current bar that the top down map contains
- inside the circle, is the battery percentage.
- left of pod expansion
- dock assist button and keybind
- expanded by default
- below pod expansion
- combined advanced power view for the roomba with other info from rover host stats
- bottom left
- pod
- has circular buttons for laser, horn, and headlight
- each button is a related icon and the keybind label for the feature
- arranged nicely
- horn button is larger, and contains an arrow to open the horn settings menu
- this pod disappears when none of these things are enabled
- if one of the button's features is not enabled, that button should go away
- bottom right
- pod
- rounded camera tilt slider, with keybind label on each end for up / down
- in the area inside the slider, show the tilt degrees
- clicking the degrees label should set camera tilt to 0
File diff suppressed because it is too large Load Diff
+640
View File
@@ -0,0 +1,640 @@
# Server administration and container migration
## Status
This document records the agreed design and implementation order. None of the work described here is implemented merely by this document.
The work is deliberately split into two phases:
1. Finish the server-side configuration, administration, persistence, backup, restore, and media-routing changes while the server still uses its current systemd deployment.
2. Containerize the already-finished application, publish images through GHCR, and add container-aware update and restart controls.
Phase 1 must be complete and verified before Phase 2 begins. Containerization must not become a second configuration migration or a reason to maintain two persistence layouts.
## Decision log
- 2026-09-13: Use an internal Node `/video` proxy. The public reverse proxy will send every site path to Node, Node will strip `/video` and stream WHEP signaling to MediaMTX on loopback, and MediaMTX port 8889 will not be exposed publicly. MediaMTX cannot independently add a WHEP base-path prefix; making `video` part of every stream name would still leave two HTTP servers competing for the public HTTPS listener.
- 2026-09-13: Preserve the existing flat `server/data` layout instead of moving established stores into decorative `state`, `cache`, or `generated` parents. Packaged application assets remain with the application.
- 2026-09-13: "The server" in the filesystem rule specifically means the main Node.js application. Every file it intentionally creates or modifies, including disposable scratch work, must be beneath `SERVER_DATA_DIR`. Installers, systemd, Docker, BlueZ, and unavoidable internal behavior of external libraries are outside that application boundary.
## Final goals
- `config.yaml` and `config.example.yaml` no longer exist.
- All operator-controlled server configuration is stored in a validated database and managed through the web UI.
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
- A complete backup can capture that one data directory consistently, and a restore can safely replace it.
- A one-time legacy importer moves an existing `config.yaml` installation into the new configuration database.
- A dedicated `/admin` application contains all server administration.
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
- Release images are built automatically and published to GHCR.
- The admin UI can restart, update, health-check, and roll back the application container without giving the main application direct Docker access.
- The final host installation contains as little project-specific material as possible: a Compose file, a data directory, and unavoidable hardware preparation.
## Important boundary: application files versus server data
The data-directory rule applies to everything mutable or instance-specific that the server reads, writes, generates, or persists at runtime. It does not mean copying the application itself into the data directory.
Packaged, read-only application material remains with the application and later inside the image:
- Server source and production dependencies
- Built web UI assets
- Static sound and image assets shipped by the repository
- MediaMTX, ffmpeg, ffprobe, neolink, and TTS tools
- Kinect and Balance Board workers
- Helper scripts shipped as part of the application
The single data directory owns:
- Server configuration and secrets
- Administrator accounts
- Identity and permission records
- Fleet reports
- Persistent service state
- Audit history
- Generated MediaMTX configuration
- Snapshots and replay segments
- Finished replays
- PTZ-generated audio
- Barcode/TTS caches
- Disposable audio-forward and replay-build work under `runtime/`
- Backup and restore coordination state
- Any future file deliberately created or modified by the Node application
Application-owned scratch work must use `SERVER_DATA_DIR/runtime`, even when it is safe to lose on restart. Tests may use the operating system temporary directory because they are not the running server application. Packaged programs and host services can manage their own internal temporary state, but any output path explicitly selected by Node must follow the single-root rule.
# Phase 1: complete the application before containerization
Phase 1 is server, web UI, installer, and migration work only. The current systemd deployment remains the runtime while these contracts are changed and verified.
## 1. Establish the single data-directory contract
The normal development and legacy-install location remains `server/data`. The path continues to be overridable through `SERVER_DATA_DIR`, which will later be set to `/data` in the container.
A representative final layout is:
```text
server/data/
├── configuration.sqlite
├── identity.sqlite
├── fleet-reports.sqlite
├── mediamtx.yml
├── existing service JSON stores
├── barcode-tts-cache/
├── rover-snapshots/
├── replay-segments/
├── replays/
├── ptz-camera-audio/
├── runtime/
│ ├── audio-forward/
│ ├── replay-builds/
│ └── room-camera-replay-builds/
└── system/
├── backup-staging/
└── restore/
```
The exact number of databases is not important. A centralized admin UI does not require unrelated services to share one SQLite connection. Keeping identity and high-volume fleet reporting in their existing databases may remain simpler, provided every database is under the same data directory.
Required work:
- Audit every server filesystem read and write.
- Make every persistent path resolve from the shared data-path helper.
- Move rover and PTZ snapshots out of `/var/lib/rover-snapshots` and into the data directory.
- Remove separate persistent replay path configuration and keep replay segments and completed replays under the data directory.
- Keep generated MediaMTX configuration at its established `data/mediamtx.yml` path.
- Keep barcode speech, PTZ speech, and similar caches under the data directory.
- Check native workers and child-process scripts for hidden working-directory assumptions.
- Update health reporting to inspect the new paths.
- Update the legacy installer so the service receives one `SERVER_DATA_DIR` rather than several unrelated persistent paths.
- Add a focused test that runs services against a temporary data directory and proves that no test artifact escapes it.
- Document which files are durable and which cache directories may be discarded.
The audit must search direct filesystem calls as well as environment-variable defaults. Existing calls that default to `/var/lib`, the repository directory, or an implicit current working directory must be corrected.
## 2. Replace YAML with a configuration database
Create a synchronous configuration service backed by `better-sqlite3`. Synchronous reads preserve the server's current startup model, in which many services load their configuration while modules are required.
The configuration database should own at least:
- The current complete configuration document
- A monotonically increasing configuration revision
- Previous configuration revisions
- The administrator account catalog and password hashes
- Persistent administrative audit events
- Database/schema migration state
The configuration schema must explicitly describe every supported field. A validation library should be used rather than assembling an ad hoc validator by hand.
Current configuration areas to migrate include:
- Server timezone and public instance identity
- Administrator accounts and Discord identities
- Inter-instance directories and profile
- LLM commentary and Overseer Control
- Barcode games
- Media and WebRTC ICE candidates
- Bandwidth-saving policy
- Audio forwarding and global audio levels
- Home Assistant, Neato, lift, entities, and button mappings
- Room cameras and PTZ camera
- Kinect and Balance Board
- Button box and barcode scanner
- Command names
- Discord bot, channels, and roles
- Social links and driver content
- Fleet-report collection, retention, privacy, and delivery
Required behavior:
- A missing value receives a documented safe default.
- Optional integrations default to disabled.
- Unknown fields are rejected rather than silently ignored.
- Invalid configuration never becomes the active revision.
- A complete revision is written atomically.
- Updates include the acting administrator and timestamp.
- Concurrent editors use revision checking so an older browser cannot overwrite a newer change silently.
- Secrets are never included in ordinary configuration responses, logs, diffs, or audit metadata.
- Secret inputs support replace and clear operations without returning the current value to the browser.
- At least one lockdown administrator must always remain.
- An administrator cannot accidentally remove the only account capable of repairing administration.
Configuration changes use one intentionally simple application rule:
1. Validate the complete proposed document.
2. Commit it as a new database revision.
3. Report that an application restart is required.
4. Let the administrator restart immediately or later.
5. Load one coherent configuration snapshot at the next process start.
Operational actions such as changing server mode, locking a rover, or issuing a rover command remain live actions and do not become restart-required configuration edits.
After migration is complete:
- Remove the YAML configuration loader.
- Remove `SERVER_CONFIG`.
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
## 3. Build the one-time legacy configuration importer
Existing installations need an explicit, bounded migration from their old `config.yaml`. This importer is not a compatibility loader and must never become a permanent second source of truth.
The importer must:
- Accept an explicitly selected legacy YAML file.
- Parse the complete legacy document.
- Map every recognized field into the new configuration schema.
- Preserve existing bcrypt administrator password hashes.
- Preserve lockdown roles and Discord IDs.
- Preserve secrets without printing them.
- Apply new defaults for fields absent from an older configuration.
- Detect unknown fields and show them in the migration report.
- Validate the entire result before writing anything.
- Refuse to overwrite an already-configured database unless an explicit replacement workflow is used.
- Support a dry-run that reports changes without writing.
- Write the imported configuration and migration metadata atomically.
- Record the source format and migration time without storing secret values in the audit event.
- Verify that the resulting configuration can be read back before considering the import successful.
The first-run UI should recognize that a legacy configuration is available and offer the import after the operator proves possession of the one-time setup code. A command-line import path should also exist for recovery and unattended migration.
After a successful import, the server must use only the database. The legacy YAML file should not be watched, re-read, or used as fallback. Removal of the old file should be an explicit final migration step after the operator has downloaded a backup or otherwise confirmed the import.
## 4. Add first-run setup
The server must boot safely with an empty data directory and without any YAML file.
Required flow:
1. Initialize the databases and safe default configuration.
2. Keep all optional external integrations disabled.
3. Generate a one-time setup code and print it to the server log.
4. Serve a restricted `/setup` application.
5. Require the setup code before creating the first lockdown administrator.
6. Offer legacy configuration import when a legacy source was explicitly provided.
7. Otherwise collect only the minimum information needed to establish the instance.
8. Permanently disable setup after the first lockdown administrator exists.
A recovery command must be available for resetting or creating a lockdown administrator from the server console. Environment variables must not act as a recurring authentication bypass on every boot.
## 5. Build the centralized admin application
Create a dedicated `/admin` route instead of continuing to expand the existing driver-page admin panel.
The application should organize existing and new controls into:
- Overview and service health
- Fleet and rover operations
- Users, administrators, verification, and permissions
- Media and bandwidth
- Discord and Home Assistant
- PTZ and room cameras
- Kinect and Balance Board
- Button box, barcode scanner, barcode games, and lift
- LLM commentary and Overseer Control
- Social links and driver content
- Fleet reports
- Application and administrative logs
- Persistent audit history
- Configuration revisions
- Backup and restore
- System restart, and later container update
Existing components and server operations should be moved or reused rather than duplicated. The identity database page and other isolated administrative pages should become sections of this centralized application where doing so preserves their existing behavior.
Authorization rules:
- Normal administrators may perform routine fleet operations.
- Lockdown administrators manage accounts, secrets, server configuration, backup restoration, and other destructive operations.
- Sensitive changes require recent password confirmation.
- Server-side authorization remains authoritative for every operation; hiding a control in React is not an access check.
Configuration forms should be explicit, typed forms. There should be no raw YAML editor and no generic JSON editor for ordinary configuration. Repeatable definitions such as cameras, entities, links, and buttons need simple add, remove, reorder, and test workflows.
## 6. Implement complete backup and restore
Everything durable living under one data directory makes the backup boundary simple, but copying live SQLite files and JSON files without coordination would not guarantee a consistent backup. The implementation must create a consistent snapshot before archiving it.
### Full backup
The primary admin action is **Download full backup**. A full backup includes the entire durable data payload:
- Configuration and secrets
- Administrator accounts
- Identity and permissions
- Fleet history
- Persistent service state
- Snapshots and replay media
- Generated and cached files that are part of the current server state
- A manifest describing the application and schema versions
The backup service must:
1. Require a lockdown administrator and recent password confirmation.
2. Enter a short maintenance/snapshot state that prevents new persistent mutations.
3. Ask services with buffered state to flush it, stop active audio/replay workers, and clear `runtime/` so FIFOs and incomplete scratch files are never archived.
4. Create consistent SQLite snapshots using SQLite's supported backup/checkpoint facilities rather than copying active WAL files blindly.
5. Copy non-database durable files into temporary staging.
6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums.
7. Create the archive in temporary storage and stream it to the browser.
8. Remove temporary staging whether the operation succeeds or fails.
9. Resume normal mutations after the consistent snapshot has been captured; archive compression does not need to hold the server in maintenance mode.
The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory.
`runtime/` is inside the filesystem boundary but is not durable backup content. Excluding it is necessary because an audio FIFO is a live process primitive rather than a regular file, and incomplete uploads or replay builds have no restore value. The backup coordinator must quiesce the owning services before clearing it so exclusion cannot disrupt active work.
An optional smaller **Download settings and state backup** may exclude explicitly regenerable, high-volume snapshots, replay segments, completed replays, and caches. This is secondary; the full backup remains the authoritative complete-server backup.
### Restore
Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation.
The restore service must:
1. Require a lockdown administrator and recent password confirmation.
2. Upload the archive into bounded staging controlled by the data directory.
3. Enforce an upload-size limit that is appropriate for full media-inclusive backups.
4. Reject absolute paths, `..` traversal, symlinks, device files, and unexpected archive structures.
5. Validate the manifest and every checksum before altering active data.
6. Check that the backup version has a supported forward migration path.
7. Display exactly what will be replaced.
8. Require a final explicit confirmation.
9. Record a pending-restore marker.
10. Gracefully stop the application.
11. Apply the restore before ordinary services open their databases on the next start.
12. Run database migrations against the restored data when necessary.
13. Start the application and verify its health.
The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation.
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally.
### Command-line recovery
Backup and restore must also have command-line entry points that use the same implementation as the admin UI. They are needed when the web server cannot start or authentication data is damaged.
The command-line tools must support:
- Creating a consistent backup while the server is stopped
- Validating a backup without applying it
- Restoring while the server is stopped
- Printing a concise manifest summary
- Refusing unsafe or malformed archives
The UI and command line must not develop separate archive formats or validation behavior.
## 7. Standardize graceful application restart
Replace the current host reboot operation with a deployment-neutral **Restart application** operation.
The restart coordinator must:
1. Authorize and acknowledge the request.
2. Stop accepting new persistent mutations.
3. Flush or close persistent stores.
4. Stop MediaMTX, ffmpeg, and native workers.
5. Close HTTP and socket listeners within a bounded timeout.
6. Exit with the status expected by the current supervisor.
During Phase 1, systemd restarts the process. During Phase 2, the container restart policy or lifecycle service restarts it. The browser should show a reconnect state and confirm the active configuration revision after reconnecting.
Host rebooting is a separate privilege and is not part of this application restart contract.
## 8. Internalize MediaMTX WHEP signaling
The current external proxy maps public `/video/<path>` requests to MediaMTX after stripping `/video`. Node should own that mapping directly.
The final request path is:
```text
Browser: /video/<stream>/whep
Node: strips /video and streams the request internally
MediaMTX: /<stream>/whep on 127.0.0.1:8889
```
Required work:
- Add a maintained HTTP proxy library rather than manually reproducing proxy semantics.
- Register the media proxy early enough that request bodies remain unmodified.
- Stream request and response bodies without buffering.
- Forward `POST`, `PATCH`, `DELETE`, authorization, content type, forwarded protocol, and relevant WHEP response headers.
- Apply bounded but media-appropriate proxy timeouts.
- Bind MediaMTX's WHEP listener to loopback.
- Generate browser WHEP URLs relative to the current site origin.
- Remove `media.whepBaseUrl` from configuration.
- Keep public and LAN ICE candidate hostnames as validated admin configuration.
- Test the exact `/video` prefix removal.
- Confirm that MediaMTX's internal HTTP authorization callback still reaches Node.
The actual WebRTC media does not pass through this HTTP proxy. MediaMTX's ICE TCP/UDP port must remain reachable by browsers.
Afterward, the public TLS proxy sends all paths for the site to Node and no longer needs a separate MediaMTX `/video` upstream.
## 9. Phase 1 verification and completion gate
Phase 1 is complete only when all of the following are true:
- The current systemd installation runs without `config.yaml`.
- A completely empty data directory can be initialized through `/setup`.
- An existing YAML installation can be imported exactly once.
- The importer reports unknown or invalid legacy values instead of discarding them.
- All mutable server state is contained by the configured data directory.
- A complete backup can be downloaded and validated.
- A restore replaces the server state only after validation and survives restart.
- Failed restore validation leaves the current server unchanged.
- Configuration, administrator accounts, and secrets survive restart.
- The final lockdown administrator cannot be removed accidentally.
- All administrative surfaces are available through `/admin` with server-side authorization.
- Configuration changes create auditable revisions and apply after restart.
- `/video` works through Node without a special public proxy rule for MediaMTX.
- Rover sockets, RTSP publishing, WHEP playback, snapshots, replays, PTZ, Discord, Home Assistant, Kinect, Balance Board, and reporting retain their intended behavior when enabled.
### Filesystem boundary implementation notes
Implemented on 2026-09-13:
- Removed the obsolete `server/src/data` fallback so there is one default data root.
- Added a shared rover-snapshot directory resolver beneath `SERVER_DATA_DIR`.
- Converted rover snapshot polling, PTZ snapshot reads, and health reporting to that resolver.
- Made the MediaMTX supervisor pass its resolved `SERVER_DATA_DIR` to runOnReady hooks.
- Converted the snapshot writer to require that data root and write to `rover-snapshots` beneath it.
- Removed the separate snapshot and replay-segment locations from the systemd unit generated by the installer.
- Made the installer create and own the canonical data and snapshot directories.
- Preserved the existing flat data layout; established SQLite, JSON, replay, cache, and generated MediaMTX paths were already within the boundary.
- Moved audio-forward FIFOs/uploads and both replay-rendering workspaces from the host temporary directory to `data/runtime`.
- Removed the configurable audio-forward runtime path so configuration cannot direct application writes outside `SERVER_DATA_DIR`.
- Kept prompts, public assets, helper binaries, native workers, and TTS assets with the packaged application because they are read-only application material.
- Left any old `/var/lib/rover-snapshots` and `/var/lib/replay-segments` directories untouched but reported during installation. Nothing reads or writes them after the upgraded service starts, and the operator can remove them after verifying the new paths on the actual server.
Local verification completed:
- Data-path helper tests passed for the default root and an overridden temporary root.
- MediaMTX supervisor testing confirmed that the resolved root reaches child hooks.
- The snapshot writer created `rover-snapshots` beneath a temporary data root and failed closed when no data root was supplied.
- All 91 server tests passed, including the new data-path and MediaMTX supervisor coverage.
- Installer and snapshot-writer shell syntax checks passed.
- Source inventory found no remaining application runtime use of the operating system temporary directory, the old snapshot/replay environment variables, or `/var/lib` paths; only tests use OS temporary directories and the installer retains a deliberate legacy-directory notice.
- Real snapshot generation and legacy-directory cleanup still require verification on the actual server during deployment.
# Phase 2: containerization and image delivery
Phase 2 packages the completed Phase 1 application. It must not introduce a second configuration source or a second persistent-data layout.
## 10. Build the production application image
Use a Fedora-based multi-stage build to remain close to the dependencies already installed by the server installer and to avoid Alpine/musl compatibility problems with native modules and the ChromeOS TTS library.
### Web UI build stage
- Install locked web UI dependencies with `npm ci`.
- Run the production Vite build.
- Copy only the built assets into the final server tree.
### Server dependency stage
- Install locked server dependencies with `npm ci --omit=dev`.
- Supply compiler tooling only in the build stage for native Node modules.
- Copy production dependencies into the final image.
### Native worker stage
- Build the Kinect worker against libfreenect/libusb.
- Build the Balance Board worker against wiiuse/BlueZ.
- Build for the target image architecture rather than copying checked-in workstation binaries.
### Packaged runtime tools
At image-build time:
- Download pinned MediaMTX and neolink releases.
- Verify checksums.
- Install ffmpeg, ffprobe, TTS engines, required GStreamer libraries, and runtime native libraries.
- Install the ChromeOS TTS library and voice data.
- Install the TTS and snapshot helper scripts.
- Run reasonable build-time smoke checks.
The actual server must never download or compile these dependencies during container startup.
### Final image
The final image should:
- Contain no compiler toolchain, Git checkout, development dependencies, or build cache.
- Run Node as a dedicated non-root user.
- Use a minimal init process to reap child processes.
- Treat `/data` as its only persistent writable location.
- Use `/tmp` only for disposable work.
- Include release version and commit metadata.
- Handle `SIGTERM` through the Phase 1 graceful shutdown coordinator.
## 11. Compose deployment
The host-visible installation should be only:
```text
multirover/
├── compose.yaml
└── data/
```
The Compose project contains:
- The main Multirover application container
- A small lifecycle container used for application update and restart
The application mounts:
```text
./data:/data
```
Host networking is the initial preferred design because it most closely preserves current rover RTSP, WebRTC ICE, UDP media, camera, and LAN integration behavior. The exact listeners must be audited before finalizing the Compose file.
Expected externally relevant listeners are:
- Node HTTP, Socket.IO, and proxied WHEP signaling on TCP 8080
- Rover RTSP publishing on TCP 8554
- WebRTC media on TCP and UDP 8189
MediaMTX WHEP on 8889, API/metrics listeners, and server-local SRT should stay on loopback unless an identified remote consumer requires otherwise.
## 12. Hardware access with minimal host setup
The host must still provide the kernel and system services that containers cannot safely configure for themselves.
Kinect requirements:
- One host udev rule granting the intended device access
- The necessary USB device mount, likely `/dev/bus/usb` because Kinect device numbering can change
- libfreenect and the worker inside the image
Balance Board requirements:
- Host Bluetooth daemon configuration required by the existing raw HID design
- Access to the host BlueZ D-Bus socket
- Only the network capabilities required by the native worker
- No fully privileged main application container
The exact capabilities and device permissions must be proven on the real server hardware. This development machine is not the actual server and cannot complete that validation.
The host should not need Node, npm, MediaMTX, ffmpeg, neolink, application source, or a Multirover systemd unit after cutover.
## 13. Health checks
Add an internal health endpoint that verifies:
- Node is accepting requests.
- Configuration initialization and migrations succeeded.
- The data directory is readable and writable.
- Required SQLite databases are usable.
- MediaMTX is running and its internal endpoint responds.
Optional remote integrations should report degraded status to administrators without forcing a container restart loop. Home Assistant, Discord, a camera, or an LLM server being offline does not mean the application process itself is unhealthy.
Compose should use the health endpoint and a restart policy suitable for unattended operation.
## 14. Restricted lifecycle container
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
A small lifecycle container should be the only component with Docker control. It should:
- Have no public network port.
- Accept requests only through a shared Unix socket under `data/system` or another private Compose-only channel.
- Operate only on the fixed Multirover application service.
- Reject arbitrary command lines, service names, image names, and Compose arguments.
- Persist update job state so it survives replacement of the application container.
- Report current version and image digest.
- Pull the configured release image.
- Restart or recreate the application container.
- Wait for the application health check.
- Retain and restore the previous image when the replacement fails.
The `/admin` System section should expose:
- Current version and image digest
- Check for update
- Update and restart
- Restart application
- Update progress and recent output
- Last update result
- Rollback result
These operations require a lockdown administrator and recent password confirmation. The browser must expect its socket to disappear, show a reconnect state, and retrieve the persistent job result after the new application becomes healthy.
The Compose contract should remain stable so ordinary application releases replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
## 15. GHCR release automation
Add repository automation that:
- Builds the production image from a clean checkout.
- Runs server tests, focused web UI tests/lint, and the production web build before publishing.
- Builds each explicitly supported server architecture.
- Publishes immutable commit/release tags to GHCR.
- Publishes one documented stable channel used by the lifecycle updater.
- Records image digests and source revision metadata.
- Avoids publishing when required verification fails.
The deployed server pulls a prebuilt image. It does not run `git pull`, `npm install`, native compilation, or web UI compilation.
## 16. Container cutover
The actual deployment migration should:
1. Download and validate a full Phase 1 backup.
2. Stop and disable the legacy Multirover systemd service.
3. Ensure no legacy MediaMTX service remains active.
4. Place the Compose file beside the existing data directory or move that directory once while the service is stopped.
5. Start the application and lifecycle containers.
6. Confirm that database migrations complete.
7. Confirm the active configuration revision and administrator access.
8. Verify rover connectivity, media publishing, WHEP playback, replay, cameras, and enabled hardware/integrations.
9. Exercise application restart through `/admin`.
10. Exercise an image update and health-check result.
11. Retain the Phase 1 backup until the container deployment has been accepted.
The old systemd application and the Compose application must never run concurrently because they would compete for HTTP and media ports.
## 17. Phase 2 completion gate
Containerization is complete when:
- A new host can start from one Compose file and an empty data directory.
- Existing state can be restored from a Phase 1 full backup.
- `./data:/data` is the only persistent application mount.
- Replacing the application container preserves all state.
- The special external `/video` MediaMTX route is unnecessary.
- The main container has no Docker socket access and is not fully privileged.
- Admin-triggered restart works.
- Admin-triggered update works and persists progress across reconnection.
- A failed image health check rolls back to the prior image.
- GHCR images are reproducibly built from repository releases.
- Kinect and Balance Board behavior has been verified on the actual host.
- Node, npm, application source, and media binaries are no longer installed directly on the host.
# Recommended implementation order
Within the two hard phase boundaries, the safest order is:
- [x] Complete the filesystem audit and single data-directory migration.
- [ ] Add the configuration schema/database and administrator storage.
- [ ] Add first-run setup and the legacy YAML importer.
- [ ] Convert every configuration consumer and remove YAML runtime loading.
- [ ] Build the centralized admin configuration UI.
- [ ] Add persistent audit history.
- [ ] Implement coordinated backup and staged restore.
- [ ] Standardize graceful application restart.
- [ ] Add the internal `/video` proxy and remove the special external route.
- [ ] Run the full Phase 1 completion gate on the legacy deployment.
- [ ] Build and verify the production application image.
- [ ] Add Compose, data mounting, networking, and hardware access.
- [ ] Add GHCR build and publication automation.
- [ ] Add the restricted lifecycle container and connect the System UI.
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
- [ ] Perform the final systemd-to-Compose cutover.
This order gives each invasive change one clear source of failures and leaves the container phase responsible for packaging and supervision rather than unfinished application architecture.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Daniel Roberts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,218 @@
# RoverPeripheral
RoverPeripheral is an ESP32 Arduino library for MultiRoombaRover peripherals.
The ESP32 reports its built-in rover roles and accessory controls to `roverd`
over USB serial.
## PlatformIO installation
Classic ESP32 DevKitC-style board:
```ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
Native-USB ESP32-S3 DevKitC:
```ini
[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
## Program structure
Include `RoverPeripheral.h` and define `configureRoverPeripheral()`:
```cpp
#include <RoverPeripheral.h>
void configureRoverPeripheral(RoverPeripheral& peripheral) {
peripheral.name("Headlight controller");
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
peripheral.addHeadlight(headlight);
}
```
The library provides `setup()` and `loop()`. Do not define them in the
peripheral program.
## Built-in rover roles
Camera tilt:
```cpp
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
peripheral.addCameraServo(cameraServo);
```
Headlight or laser:
```cpp
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
peripheral.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
peripheral.addLaser(laser);
```
These registrations use the existing camera, headlight, and laser controls in
the rover UI. They do not create accessory controls.
## Accessory controls
Controls appear in registration order. Each control name must be unique within
the peripheral. The name is also used as the control identifier.
### Servo slider
```cpp
SliderControlConfig position;
position.name = "Arm position";
position.minimum = 0;
position.maximum = 180;
ServoOutput servo;
servo.pin = 13;
peripheral.addSlider(position, servo);
```
### PWM slider
```cpp
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput light;
light.pin = 17;
peripheral.addSlider(brightness, light);
```
### Digital button
```cpp
ButtonControlConfig workLight;
workLight.name = "Work light";
workLight.mode = ButtonMode::Toggle;
DigitalOutput light;
light.pin = 21;
light.polarity = OutputPolarity::ActiveHigh;
peripheral.addButton(workLight, light);
```
### Custom slider
```cpp
void setMotorSpeed(int value) {
// Apply value to the device.
}
SliderControlConfig speed;
speed.name = "Motor speed";
speed.minimum = 0;
speed.maximum = 100;
peripheral.addSlider(speed, setMotorSpeed);
```
### Custom button
```cpp
void setMotorRunning(bool running) {
// Start or stop the device.
}
ButtonControlConfig motor;
motor.name = "Motor";
motor.mode = ButtonMode::Momentary;
peripheral.addButton(motor, setMotorRunning);
```
A momentary bool callback receives `true` on press and `false` on release. A
zero-argument callback can be used for a one-shot momentary action.
### Number input
```cpp
void setRepeatCount(int value) {
// Store or apply value.
}
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
peripheral.addNumber(repeats, setRepeatCount);
```
### Text input
```cpp
void setDisplayMessage(const String& value) {
// Store or display value.
}
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
peripheral.addText(message, setDisplayMessage);
```
## Recurring work
Define `updateRoverPeripheral()` when the program needs recurring non-blocking
work:
```cpp
void updateRoverPeripheral() {
// Update a state machine or device.
}
```
Callbacks and `updateRoverPeripheral()` must not block serial processing.
`Serial` is reserved for Firmata and must not be used for debug output.
## License
MIT
@@ -0,0 +1,76 @@
#include <RoverPeripheral.h>
namespace {
constexpr uint8_t kActionPin = 21;
int repeatCount = 1;
String displayMessage;
void setActionActive(bool pressed) {
digitalWrite(kActionPin, pressed ? HIGH : LOW);
}
void setRepeatCount(int value) {
repeatCount = value;
}
void setDisplayMessage(const String& value) {
displayMessage = value;
}
} // namespace
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Complete rover peripheral");
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
io.addCameraServo(cameraServo);
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
io.addLaser(laser);
pinMode(kActionPin, OUTPUT);
digitalWrite(kActionPin, LOW);
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput brightnessOutput;
brightnessOutput.pin = 17;
io.addSlider(brightness, brightnessOutput);
ButtonControlConfig action;
action.name = "Special action";
action.mode = ButtonMode::Momentary;
io.addButton(action, setActionActive);
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
io.addNumber(repeats, setRepeatCount);
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
io.addText(message, setDisplayMessage);
}
@@ -0,0 +1,11 @@
#include <RoverPeripheral.h>
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Headlight controller");
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
}
@@ -0,0 +1,26 @@
{
"$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json",
"name": "RoverPeripheral",
"version": "2.0.1",
"description": "Create self-describing ESP32 hardware controls for MultiRoombaRover",
"keywords": [
"esp32",
"firmata",
"robotics",
"rover"
],
"repository": {
"type": "git",
"url": "https://github.com/legop3/MultiRoombaRover.git"
},
"homepage": "https://github.com/legop3/MultiRoombaRover/tree/main/esp32/libraries/RoverPeripheralFirmata",
"license": "MIT",
"frameworks": "arduino",
"platforms": "espressif32",
"headers": "RoverPeripheral.h",
"dependencies": {
"ConfigurableFirmata": "https://github.com/firmata/ConfigurableFirmata.git#3.2.0",
"bblanchon/ArduinoJson": "^7.4.2",
"madhephaestus/ESP32Servo": "^3.0.8"
}
}
@@ -0,0 +1,78 @@
#include "RoverPeripheral.h"
#include "internal/RoverPeripheralFirmata.h"
RoverPeripheral::RoverPeripheral()
: implementation_(new RoverPeripheralFirmata("Rover peripheral")) {}
RoverPeripheral::~RoverPeripheral() {
delete implementation_;
}
void RoverPeripheral::name(const String& peripheralName) {
implementation_->setName(peripheralName);
}
void RoverPeripheral::addCameraServo(const RoverCameraServoConfig& config) {
implementation_->addRoverCameraServo(config);
}
void RoverPeripheral::addHeadlight(const RoverDigitalOutputConfig& config) {
implementation_->addRoverHeadlight(config);
}
void RoverPeripheral::addLaser(const RoverDigitalOutputConfig& config) {
implementation_->addRoverLaser(config);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, const ServoOutput& output) {
implementation_->addServoSlider(config, output);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, const PwmOutput& output) {
implementation_->addPwmSlider(config, output);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, const DigitalOutput& output) {
implementation_->addDigitalButton(config, output);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, SliderCallback callback) {
implementation_->addSlider(config, callback);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
implementation_->addButton(config, callback);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, ActionCallback callback) {
// One-shot callbacks apply only to momentary buttons. A toggle requires the
// bool callback overload because application code must receive its new state.
if (config.mode != ButtonMode::Momentary) {
abort();
}
implementation_->addButton(
config,
[callback](bool pressed) {
if (pressed && callback) {
callback();
}
}
);
}
void RoverPeripheral::addNumber(const NumberControlConfig& config, NumberCallback callback) {
implementation_->addNumber(config, callback);
}
void RoverPeripheral::addText(const TextControlConfig& config, TextCallback callback) {
implementation_->addText(config, callback);
}
void RoverPeripheral::begin(FirmataExt& extension) {
implementation_->begin(extension);
}
void RoverPeripheral::update() {
implementation_->update();
}
@@ -0,0 +1,156 @@
#pragma once
#include <Arduino.h>
#include <functional>
/** Describes whether a logical on value drives an output pin high or low. */
enum class OutputPolarity {
ActiveHigh,
ActiveLow,
};
/** Selects whether a button retains its state or is active only while held. */
enum class ButtonMode {
Toggle,
Momentary,
};
/** Configuration for the rover's existing camera-tilt control. */
struct RoverCameraServoConfig {
uint8_t pin = 0;
float minimumAngleDegrees = -15;
float maximumAngleDegrees = 30;
float homeAngleDegrees = 0;
float nudgeDegrees = 2;
uint16_t minimumPulseMicroseconds = 900;
uint16_t maximumPulseMicroseconds = 2100;
bool allowRawPulse = false;
bool inverted = false;
};
/** Configuration for the rover's existing headlight or laser control. */
struct RoverDigitalOutputConfig {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
bool initiallyOn = false;
};
/** Shared display and range settings for a slider control. */
struct SliderControlConfig {
String name;
int minimum = 0;
int maximum = 100;
};
/** Shared display and interaction settings for a button control. */
struct ButtonControlConfig {
String name;
ButtonMode mode = ButtonMode::Momentary;
};
/** Shared display and range settings for a number input. */
struct NumberControlConfig {
String name;
int minimum = 0;
int maximum = 100;
};
/** Shared display and length settings for a text input. */
struct TextControlConfig {
String name;
size_t maximumLength = 32;
};
/** Selects a standard Firmata servo as the destination for a slider. */
struct ServoOutput {
uint8_t pin = 0;
};
/** Selects an ESP32 PWM pin as the destination for a slider. */
struct PwmOutput {
uint8_t pin = 0;
};
/** Selects an ESP32 digital pin as the destination for a button. */
struct DigitalOutput {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
};
using SliderCallback = std::function<void(int)>;
using ButtonCallback = std::function<void(bool)>;
using ActionCallback = std::function<void()>;
using NumberCallback = std::function<void(int)>;
using TextCallback = std::function<void(const String&)>;
class FirmataExt;
class RoverPeripheralFirmata;
/**
* Registration API for a self-describing rover peripheral.
*
* A sketch constructs each configuration one field at a time and registers it
* in configureRoverPeripheral(). Serial and protocol setup stay in the library.
*/
class RoverPeripheral {
public:
RoverPeripheral();
~RoverPeripheral();
RoverPeripheral(const RoverPeripheral&) = delete;
RoverPeripheral& operator=(const RoverPeripheral&) = delete;
/** Sets the peripheral name shown above its accessory controls. */
void name(const String& peripheralName);
/** Registers the rover's existing camera-tilt control. */
void addCameraServo(const RoverCameraServoConfig& config);
/** Registers the rover's existing headlight control. */
void addHeadlight(const RoverDigitalOutputConfig& config);
/** Registers the rover's existing laser control. */
void addLaser(const RoverDigitalOutputConfig& config);
/** Registers a slider backed by a standard Firmata servo output. */
void addSlider(const SliderControlConfig& config, const ServoOutput& output);
/** Registers a slider backed by an ESP32 PWM output. */
void addSlider(const SliderControlConfig& config, const PwmOutput& output);
/** Registers a button backed by an ESP32 digital output. */
void addButton(const ButtonControlConfig& config, const DigitalOutput& output);
/** Registers a slider handled by application code. */
void addSlider(const SliderControlConfig& config, SliderCallback callback);
/** Registers a button whose callback receives its logical state. */
void addButton(const ButtonControlConfig& config, ButtonCallback callback);
/** Registers a momentary button whose callback runs only on press. */
void addButton(const ButtonControlConfig& config, ActionCallback callback);
/** Registers a number input handled by application code. */
void addNumber(const NumberControlConfig& config, NumberCallback callback);
/** Registers a text input handled by application code. */
void addText(const TextControlConfig& config, TextCallback callback);
private:
// The implementation is opaque so importing this header does not expose any
// Firmata types or require firmware authors to understand the wire protocol.
RoverPeripheralFirmata* implementation_;
void begin(FirmataExt& extension);
void update();
friend void setup();
friend void loop();
};
/** Called once by the library after Arduino and Serial initialization. */
void configureRoverPeripheral(RoverPeripheral& peripheral);
/** Optional non-blocking hook for recurring application work. */
void updateRoverPeripheral();
@@ -0,0 +1,46 @@
#include "RoverPeripheral.h"
#include <ConfigurableFirmata.h>
#include <FirmataExt.h>
namespace {
FirmataExt firmataExtension;
RoverPeripheral peripheral;
} // namespace
// A weak no-op preserves the zero-boilerplate case while allowing a sketch to
// define the same function when animations or state machines need regular work.
void __attribute__((weak)) updateRoverPeripheral() {}
void setup() {
// The public configuration hook runs after Arduino initialization, allowing
// peripheral code to safely use pinMode() and initialize third-party devices.
Serial.begin(115200);
configureRoverPeripheral(peripheral);
// ConfigurableFirmata batches reads on ESP32-class boards. Arduino's default
// one-second Stream timeout would delay short commands while waiting for the
// batch buffer to fill, so consume only bytes that have already arrived.
Serial.setTimeout(0);
Firmata.begin(Serial);
peripheral.begin(firmataExtension);
// Applying a normal Firmata reset after registration establishes every
// declared initial output and makes the first host connection deterministic.
Firmata.parse(SYSTEM_RESET);
}
void loop() {
// ConfigurableFirmata retains partial parser state between iterations. Stop
// after each complete message so user update work cannot be starved by a
// sustained burst, while ordinary short commands are still drained at once.
while (Firmata.available()) {
Firmata.processInput();
if (!Firmata.isParsingMessage()) {
break;
}
}
peripheral.update();
updateRoverPeripheral();
}
@@ -0,0 +1,521 @@
#include "RoverPeripheralFirmata.h"
namespace {
constexpr byte kPeripheralFeature = 0x01;
constexpr byte kDescribeOperation = 0x00;
constexpr byte kDescriptionOperation = 0x01;
constexpr byte kControlOperation = 0x02;
const char* buttonModeName(ButtonMode mode) {
return mode == ButtonMode::Toggle ? "toggle" : "momentary";
}
const char* outputTypeName(uint8_t value) {
switch (value) {
case 0:
return "servo";
case 1:
return "pwm";
case 2:
return "digital";
default:
return "custom";
}
}
} // namespace
RoverPeripheralFirmata* RoverPeripheralFirmata::instance_ = nullptr;
RoverPeripheralFirmata::RoverPeripheralFirmata(const String& name) : name_(name) {}
void RoverPeripheralFirmata::setName(const String& name) {
if (name.length() == 0) {
// A blank heading makes multiple attached peripherals impossible to
// distinguish. Treat it as a firmware-authoring error at startup rather
// than advertising ambiguous controls to the rover.
abort();
}
name_ = name;
}
void RoverPeripheralFirmata::validateControlName(const String& name) const {
if (name.length() == 0) {
// Registration errors are programmer errors discovered during setup. A
// hard stop is preferable to advertising a partially usable device whose
// behavior depends on which malformed control the driver touches first.
abort();
}
for (const ControlRegistration& existing : controls_) {
if (existing.id == name) {
abort();
}
}
}
void RoverPeripheralFirmata::validateRange(const String& name, int minimum, int maximum) const {
if (name.length() == 0 || minimum > maximum) {
abort();
}
}
void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, const ServoOutput& output) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Servo;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.pin = output.pin;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addPwmSlider(const SliderControlConfig& config, const PwmOutput& output) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Pwm;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.pin = output.pin;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output) {
validateControlName(config.name);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Button;
control.output = OutputType::Digital;
control.buttonMode = config.mode;
control.pin = output.pin;
control.polarity = output.polarity;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addSlider(const SliderControlConfig& config, SliderCallback callback) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Custom;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.sliderCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
validateControlName(config.name);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Button;
control.output = OutputType::Custom;
control.buttonMode = config.mode;
control.buttonCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addNumber(const NumberControlConfig& config, NumberCallback callback) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Number;
control.output = OutputType::Custom;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.numberCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addText(const TextControlConfig& config, TextCallback callback) {
validateControlName(config.name);
if (config.maximumLength == 0) {
abort();
}
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Text;
control.output = OutputType::Custom;
control.maximumLength = config.maximumLength;
control.textCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addRoverCameraServo(const RoverCameraServoConfig& config) {
cameraServo_ = config;
hasCameraServo_ = true;
}
void RoverPeripheralFirmata::addRoverHeadlight(const RoverDigitalOutputConfig& config) {
headlight_ = config;
hasHeadlight_ = true;
}
void RoverPeripheralFirmata::addRoverLaser(const RoverDigitalOutputConfig& config) {
laser_ = config;
hasLaser_ = true;
}
void RoverPeripheralFirmata::begin(FirmataExt& extension) {
if (instance_ != nullptr && instance_ != this) {
abort();
}
instance_ = this;
extension.addFeature(*this);
// Discovery uses Firmata's standard REPORT_FIRMWARE query to distinguish a
// rover peripheral from unrelated Firmata devices. The helper owns this
// identity so every sketch gets it without repeating protocol boilerplate.
Firmata.setFirmwareNameAndVersion("RoverPeripheralFirmata", 1, 0);
// SET_DIGITAL_PIN_VALUE is a fixed Firmata command rather than SysEx, so it
// cannot travel through FirmataFeature::handleSysex. Firmata exposes one
// callback for it and this peripheral owns the standard output implementation.
Firmata.attach(SET_DIGITAL_PIN_VALUE, digitalPinValueCallback);
Firmata.attach(SYSTEM_RESET, systemResetCallback);
}
void RoverPeripheralFirmata::update() {
// Custom callbacks execute synchronously from Firmata's parser for now. This
// method intentionally remains available so future non-blocking peripheral
// work can be serviced without changing the sketch's main loop shape.
}
void RoverPeripheralFirmata::handleCapability(byte pin) {
if (!IS_PIN_DIGITAL(pin)) {
return;
}
// The peripheral supports the output modes roverd may select. Capability
// reporting stays standard Firmata, so the Linux probe can also inspect it
// with any other conforming client.
Firmata.write(PIN_MODE_OUTPUT);
Firmata.write(1);
if (IS_PIN_PWM(pin)) {
Firmata.write(PIN_MODE_PWM);
Firmata.write(DEFAULT_PWM_RESOLUTION);
}
Firmata.write(PIN_MODE_SERVO);
Firmata.write(14);
}
boolean RoverPeripheralFirmata::handlePinMode(byte pin, int mode) {
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
return false;
}
// A pin can only have one active hardware generator. Detaching a previous
// servo before switching modes prevents it from continuing to pulse after a
// later digital or PWM configuration takes ownership of the pin.
if (mode != PIN_MODE_SERVO) {
detachServo(pin);
}
switch (mode) {
case PIN_MODE_OUTPUT:
pinMode(PIN_TO_DIGITAL(pin), OUTPUT);
digitalWrite(PIN_TO_DIGITAL(pin), LOW);
Firmata.setPinState(pin, 0);
return true;
case PIN_MODE_PWM:
if (!IS_PIN_PWM(pin)) {
return false;
}
pinMode(PIN_TO_PWM(pin), OUTPUT);
analogWrite(PIN_TO_PWM(pin), 0);
Firmata.setPinState(pin, 0);
return true;
case PIN_MODE_SERVO:
attachServo(pin);
Firmata.setPinState(pin, 0);
return true;
default:
return false;
}
}
boolean RoverPeripheralFirmata::handleSysex(byte command, byte argc, byte* argv) {
if (command == kPeripheralFeature) {
if (argc == 0) {
return true;
}
if (argv[0] == kDescribeOperation) {
buildAndSendDescription();
} else if (argv[0] == kControlOperation) {
dispatchCustomControl(argc, argv);
}
return true;
}
if (command == SERVO_CONFIG && argc >= 5) {
const byte pin = argv[0];
const int minimumPulse = argv[1] | (argv[2] << 7);
const int maximumPulse = argv[3] | (argv[4] << 7);
if (pin < TOTAL_PINS && IS_PIN_DIGITAL(pin)) {
Firmata.setPinMode(pin, PIN_MODE_SERVO);
attachServo(pin, minimumPulse, maximumPulse);
}
return true;
}
if (command == EXTENDED_ANALOG && argc >= 2) {
const byte pin = argv[0];
if (pin >= TOTAL_PINS) {
return true;
}
int value = 0;
// Extended analog values contain a variable number of seven-bit chunks.
// Reassembling every received chunk keeps servo angles and PWM values fully
// compatible with normal Firmata clients rather than assuming eight bits.
for (byte index = 1; index < argc && index <= 4; ++index) {
value |= static_cast<int>(argv[index]) << (7 * (index - 1));
}
const byte mode = Firmata.getPinMode(pin);
if (mode == PIN_MODE_PWM && IS_PIN_PWM(pin)) {
analogWrite(PIN_TO_PWM(pin), value);
Firmata.setPinState(pin, value);
} else if (mode == PIN_MODE_SERVO && servos_[pin] != nullptr) {
servos_[pin]->write(value);
Firmata.setPinState(pin, value);
}
return true;
}
return false;
}
void RoverPeripheralFirmata::reset() {
for (byte pin = 0; pin < TOTAL_PINS; ++pin) {
detachServo(pin);
}
// Built-in role defaults are applied on Firmata reset as well as boot. This
// makes reconnecting a client deterministic without creating a second state
// model on the ESP32.
if (hasHeadlight_) {
pinMode(headlight_.pin, OUTPUT);
const bool physicalHigh = headlight_.initiallyOn != (headlight_.polarity == OutputPolarity::ActiveLow);
writeDigitalPin(headlight_.pin, physicalHigh);
}
if (hasLaser_) {
pinMode(laser_.pin, OUTPUT);
const bool physicalHigh = laser_.initiallyOn != (laser_.polarity == OutputPolarity::ActiveLow);
writeDigitalPin(laser_.pin, physicalHigh);
}
}
void RoverPeripheralFirmata::buildAndSendDescription() {
JsonDocument document;
document["name"] = name_;
if (hasCameraServo_ || hasHeadlight_ || hasLaser_) {
JsonObject roverControls = document["roverControls"].to<JsonObject>();
if (hasCameraServo_) {
JsonObject servo = roverControls["cameraServo"].to<JsonObject>();
servo["pin"] = cameraServo_.pin;
servo["minimumAngleDegrees"] = cameraServo_.minimumAngleDegrees;
servo["maximumAngleDegrees"] = cameraServo_.maximumAngleDegrees;
servo["homeAngleDegrees"] = cameraServo_.homeAngleDegrees;
servo["nudgeDegrees"] = cameraServo_.nudgeDegrees;
servo["minimumPulseMicroseconds"] = cameraServo_.minimumPulseMicroseconds;
servo["maximumPulseMicroseconds"] = cameraServo_.maximumPulseMicroseconds;
servo["allowRawPulse"] = cameraServo_.allowRawPulse;
servo["inverted"] = cameraServo_.inverted;
}
auto addDigitalRole = [&roverControls](const char* key, const RoverDigitalOutputConfig& config) {
JsonObject role = roverControls[key].to<JsonObject>();
role["pin"] = config.pin;
role["activeLow"] = config.polarity == OutputPolarity::ActiveLow;
role["initiallyOn"] = config.initiallyOn;
};
if (hasHeadlight_) {
addDigitalRole("headlight", headlight_);
}
if (hasLaser_) {
addDigitalRole("laser", laser_);
}
}
JsonArray controls = document["controls"].to<JsonArray>();
for (const ControlRegistration& registration : controls_) {
JsonObject control = controls.add<JsonObject>();
control["id"] = registration.id;
control["name"] = registration.name;
switch (registration.type) {
case ControlType::Slider:
control["type"] = "slider";
control["min"] = registration.minimum;
control["max"] = registration.maximum;
break;
case ControlType::Button:
control["type"] = "button";
control["mode"] = buttonModeName(registration.buttonMode);
break;
case ControlType::Number:
control["type"] = "number";
control["min"] = registration.minimum;
control["max"] = registration.maximum;
break;
case ControlType::Text:
control["type"] = "text";
control["maxLength"] = registration.maximumLength;
break;
}
JsonObject output = control["output"].to<JsonObject>();
output["type"] = outputTypeName(static_cast<uint8_t>(registration.output));
if (registration.output != OutputType::Custom) {
output["pin"] = registration.pin;
}
if (registration.output == OutputType::Digital && registration.polarity == OutputPolarity::ActiveLow) {
output["activeLow"] = true;
}
}
String payload;
serializeJson(document, payload);
// ConfigurableFirmata's convenience sendSysex takes a byte-sized raw length.
// Descriptions can exceed that, so write the standard framing and each 7-bit
// pair directly. This remains one ordinary Firmata SysEx message on the wire.
Firmata.startSysex();
Firmata.write(kPeripheralFeature);
Firmata.write(kDescriptionOperation);
for (size_t index = 0; index < payload.length(); ++index) {
Firmata.sendValueAsTwo7bitBytes(static_cast<uint8_t>(payload[index]));
}
Firmata.endSysex();
}
void RoverPeripheralFirmata::dispatchCustomControl(byte argc, byte* argv) {
if (argc < 3 || ((argc - 1) % 2) != 0) {
Firmata.sendString(F("Invalid rover control payload"));
return;
}
String decoded;
decoded.reserve((argc - 1) / 2);
for (byte index = 1; index + 1 < argc; index += 2) {
if (argv[index + 1] > 1) {
Firmata.sendString(F("Invalid rover control encoding"));
return;
}
decoded += static_cast<char>(argv[index] | (argv[index + 1] << 7));
}
JsonDocument document;
if (deserializeJson(document, decoded) != DeserializationError::Ok) {
Firmata.sendString(F("Invalid rover control JSON"));
return;
}
const String controlID = document["control"].as<String>();
for (ControlRegistration& registration : controls_) {
if (registration.id != controlID || registration.output != OutputType::Custom) {
continue;
}
// The registration type is the source of truth for value conversion. This
// prevents an unexpected JSON value from silently selecting a different
// callback signature or invoking unrelated application behavior.
switch (registration.type) {
case ControlType::Slider:
if (registration.sliderCallback) {
registration.sliderCallback(document["value"].as<int>());
}
break;
case ControlType::Button:
if (registration.buttonCallback) {
registration.buttonCallback(document["value"].as<bool>());
}
break;
case ControlType::Number:
if (registration.numberCallback) {
registration.numberCallback(document["value"].as<int>());
}
break;
case ControlType::Text:
if (registration.textCallback) {
String value = document["value"].as<String>();
if (value.length() > registration.maximumLength) {
value.remove(registration.maximumLength);
}
registration.textCallback(value);
}
break;
}
return;
}
Firmata.sendString(F("Unknown rover control"));
}
void RoverPeripheralFirmata::writeDigitalPin(byte pin, bool physicalHigh) {
// Standard Firmata digital values represent the electrical pin level. roverd
// applies the advertised activeLow mapping before sending a command, keeping
// this firmware compatible with raw Firmata clients and avoiding inversion in
// two different layers.
digitalWrite(PIN_TO_DIGITAL(pin), physicalHigh ? HIGH : LOW);
Firmata.setPinState(pin, physicalHigh ? 1 : 0);
}
void RoverPeripheralFirmata::attachServo(byte pin, int minimumPulseMicroseconds, int maximumPulseMicroseconds) {
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
return;
}
if (servos_[pin] == nullptr) {
servos_[pin] = new Servo();
}
if (servos_[pin]->attached()) {
servos_[pin]->detach();
}
if (minimumPulseMicroseconds > 0 && maximumPulseMicroseconds > minimumPulseMicroseconds) {
servos_[pin]->attach(PIN_TO_SERVO(pin), minimumPulseMicroseconds, maximumPulseMicroseconds);
} else {
servos_[pin]->attach(PIN_TO_SERVO(pin));
}
}
void RoverPeripheralFirmata::detachServo(byte pin) {
if (pin >= TOTAL_PINS || servos_[pin] == nullptr) {
return;
}
if (servos_[pin]->attached()) {
servos_[pin]->detach();
}
delete servos_[pin];
servos_[pin] = nullptr;
}
void RoverPeripheralFirmata::digitalPinValueCallback(byte pin, int value) {
if (instance_ == nullptr || pin >= TOTAL_PINS || Firmata.getPinMode(pin) != PIN_MODE_OUTPUT) {
return;
}
// Polarity is advertised by the peripheral and applied by roverd before this
// standard raw pin-level command reaches the ESP32.
instance_->writeDigitalPin(pin, value != 0);
}
void RoverPeripheralFirmata::systemResetCallback() {
if (instance_ != nullptr) {
instance_->reset();
}
}
@@ -0,0 +1,99 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ConfigurableFirmata.h>
#include <ESP32Servo.h>
#include <FirmataExt.h>
#include <RoverPeripheral.h>
#include <vector>
/*
* RoverPeripheralFirmata is the protocol-facing implementation behind the
* small RoverPeripheral public facade. Keeping this class private prevents
* peripheral sketches from depending on Firmata types while ordinary Firmata
* tooling can still use digital, PWM, and servo commands on the same stream.
*/
class RoverPeripheralFirmata : public FirmataFeature {
public:
explicit RoverPeripheralFirmata(const String& name);
void setName(const String& name);
void addServoSlider(const SliderControlConfig& config, const ServoOutput& output);
void addPwmSlider(const SliderControlConfig& config, const PwmOutput& output);
void addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output);
void addSlider(const SliderControlConfig& config, SliderCallback callback);
void addButton(const ButtonControlConfig& config, ButtonCallback callback);
void addNumber(const NumberControlConfig& config, NumberCallback callback);
void addText(const TextControlConfig& config, TextCallback callback);
void addRoverCameraServo(const RoverCameraServoConfig& config);
void addRoverHeadlight(const RoverDigitalOutputConfig& config);
void addRoverLaser(const RoverDigitalOutputConfig& config);
void begin(FirmataExt& extension);
void update();
// FirmataFeature methods let FirmataExt route standard and custom SysEx
// operations through the same parser that owns the serial connection.
void handleCapability(byte pin) override;
boolean handlePinMode(byte pin, int mode) override;
boolean handleSysex(byte command, byte argc, byte* argv) override;
void reset() override;
private:
enum class ControlType {
Slider,
Button,
Number,
Text,
};
enum class OutputType {
Servo,
Pwm,
Digital,
Custom,
};
struct ControlRegistration {
String id;
String name;
ControlType type;
OutputType output;
int minimum = 0;
int maximum = 0;
size_t maximumLength = 0;
ButtonMode buttonMode = ButtonMode::Momentary;
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
SliderCallback sliderCallback;
ButtonCallback buttonCallback;
NumberCallback numberCallback;
TextCallback textCallback;
};
String name_;
std::vector<ControlRegistration> controls_;
bool hasCameraServo_ = false;
bool hasHeadlight_ = false;
bool hasLaser_ = false;
RoverCameraServoConfig cameraServo_;
RoverDigitalOutputConfig headlight_;
RoverDigitalOutputConfig laser_;
Servo* servos_[TOTAL_PINS] = {};
void validateControlName(const String& name) const;
void validateRange(const String& name, int minimum, int maximum) const;
void buildAndSendDescription();
void dispatchCustomControl(byte argc, byte* argv);
void writeDigitalPin(byte pin, bool enabled);
void attachServo(byte pin, int minimumPulseMicroseconds = -1, int maximumPulseMicroseconds = -1);
void detachServo(byte pin);
static RoverPeripheralFirmata* instance_;
static void digitalPinValueCallback(byte pin, int value);
static void systemResetCallback();
};
@@ -0,0 +1,25 @@
[platformio]
default_envs = esp32dev
[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
lib_deps =
; Install the local package through PlatformIO's dependency manager so this
; reference project exercises the same transitive dependency behavior as an
; external project using the published Registry package.
RoverPeripheral=file://../libraries/RoverPeripheralFirmata
; This is the generic ESP32-WROOM-32/DevKitC target used by boards carrying a
; CH340 or CP210x USB-to-UART bridge. Linux normally exposes it as ttyUSB*.
[env:esp32dev]
board = esp32dev
; Native USB boards use the same sketch and Firmata stream. These flags make the
; ESP32-S3's USB CDC serial port active at boot, normally appearing as ttyACM*.
[env:esp32-s3-devkitc-1]
board = esp32-s3-devkitc-1
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
+97
View File
@@ -0,0 +1,97 @@
#include <RoverPeripheral.h>
namespace {
// Every example pin is present on both the classic ESP32 DevKitC and the
// ESP32-S3 DevKitC. GPIO 19 and 20 are deliberately avoided because native-USB
// S3 boards use them for USB D- and D+.
constexpr uint8_t kSpecialActionPin = 21;
int repeatCount = 1;
String displayMessage;
void runSpecialAction(bool pressed) {
// Receiving both button edges lets application hardware remain active only
// while the driver holds the momentary control.
digitalWrite(kSpecialActionPin, pressed ? HIGH : LOW);
}
void setRepeatCount(int value) {
// A real device can use this value when it starts its next animation or
// actuator sequence. Storing it keeps this reference callback non-blocking.
repeatCount = value;
}
void setDisplayMessage(const String& value) {
// Display hardware can render the stored value from updateRoverPeripheral().
// Avoiding Serial output is important because Serial belongs to Firmata.
displayMessage = value;
}
} // namespace
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Rover GPIO");
// Standard roles retain the rover's existing HUD controls while moving the
// electrical outputs to this ESP32 on either a Pi or laptop rover host.
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
io.addCameraServo(cameraServo);
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
io.addLaser(laser);
pinMode(kSpecialActionPin, OUTPUT);
digitalWrite(kSpecialActionPin, LOW);
// Accessory controls render in precisely this registration order.
SliderControlConfig servoPosition;
servoPosition.name = "Servo position";
servoPosition.minimum = 0;
servoPosition.maximum = 180;
ServoOutput servoOutput;
servoOutput.pin = 13;
io.addSlider(servoPosition, servoOutput);
SliderControlConfig lightBrightness;
lightBrightness.name = "Light brightness";
lightBrightness.minimum = 0;
lightBrightness.maximum = 255;
PwmOutput lightOutput;
lightOutput.pin = 17;
io.addSlider(lightBrightness, lightOutput);
ButtonControlConfig specialAction;
specialAction.name = "Special action";
specialAction.mode = ButtonMode::Momentary;
io.addButton(specialAction, runSpecialAction);
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
io.addNumber(repeats, setRepeatCount);
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
io.addText(message, setDisplayMessage);
}
+10 -4
View File
@@ -25,10 +25,6 @@ type CameraServo struct {
closed bool
}
const maxServoDegPerSec = 60.0
const servoStepInterval = 20 * time.Millisecond
const servoAngleEpsilon = 0.01
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("camera servo disabled")
@@ -132,6 +128,16 @@ func (s *CameraServo) CurrentAngle() float64 {
return s.currentAngle
}
// Configuration reports the effective public behavior advertised to the
// server. The native implementation simply returns its validated YAML config.
func (s *CameraServo) Configuration() CameraServoConfig {
return s.cfg
}
func (s *CameraServo) BackendDescription() string {
return "native GPIO"
}
func (s *CameraServo) applyPulseLocked(micros int) {
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
+11 -4
View File
@@ -11,10 +11,9 @@ type CameraServo struct{}
func NewCameraServo(_ CameraServoConfig, _ *log.Logger) (*CameraServo, error) {
/*
The Debian laptop profile starts with the laptop's built-in webcam and no
Pi PWM servo. If a laptop rover eventually grows an external servo board,
it should get its own implementation instead of reusing Raspberry Pi GPIO
assumptions.
This constructor represents only native host GPIO. The shared startup
resolver selects the normal Firmata implementation when an ESP32 provides
the role, so external hardware is not laptop-specific code.
*/
return nil, fmt.Errorf("camera servo not supported in the debian-laptop build")
}
@@ -36,3 +35,11 @@ func (c *CameraServo) SetPulseWidth(micros int) error {
func (c *CameraServo) CurrentAngle() float64 {
return 0
}
func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{}
}
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+8
View File
@@ -30,3 +30,11 @@ func (c *CameraServo) SetPulseWidth(micros int) error {
func (c *CameraServo) CurrentAngle() float64 {
return 0
}
func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{}
}
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"time"
roverd "multiroombarover/pi/roverd"
"github.com/tarm/serial"
)
func main() {
var portName string
var baud int
var timeout time.Duration
var startupWait time.Duration
var controlID string
var rawValue string
flag.StringVar(&portName, "port", "", "serial device, for example /dev/ttyUSB0 or /dev/ttyACM0")
flag.IntVar(&baud, "baud", 115200, "Firmata serial baud rate")
flag.DurationVar(&timeout, "timeout", 5*time.Second, "timeout for each Firmata response")
flag.DurationVar(&startupWait, "startup-wait", 2*time.Second, "time allowed for boards that reset when the port opens")
flag.StringVar(&controlID, "control", "", "optional declared control ID to exercise")
flag.StringVar(&rawValue, "value", "", "JSON value for -control, such as 90, true, or \"hello\"")
flag.Parse()
if portName == "" {
log.Fatal("-port is required")
}
if (controlID == "") != (rawValue == "") {
log.Fatal("-control and -value must be provided together")
}
port, err := serial.OpenPort(&serial.Config{
Name: portName,
Baud: baud,
ReadTimeout: 100 * time.Millisecond,
})
if err != nil {
log.Fatalf("open %s: %v", portName, err)
}
defer port.Close()
// CH340 and native-USB development boards may reset when the host opens the
// port. Waiting here makes the same probe work with both connection styles
// without baking that diagnostic delay into the production Firmata client.
time.Sleep(startupWait)
rootContext, cancelRoot := context.WithCancel(context.Background())
defer cancelRoot()
client := roverd.NewFirmataClient(port)
client.Start(rootContext)
firmware, err := withTimeout(timeout, client.QueryFirmware)
if err != nil {
log.Fatalf("query firmware: %v", err)
}
fmt.Printf("Firmata firmware: %s %d.%d\n", firmware.Name, firmware.Major, firmware.Minor)
capabilities, err := withTimeout(timeout, client.QueryCapabilities)
if err != nil {
log.Fatalf("query capabilities: %v", err)
}
fmt.Printf("Firmata pins described: %d\n", len(capabilities))
description, err := withTimeout(timeout, client.Describe)
if err != nil {
log.Fatalf("describe rover peripheral: %v", err)
}
formatted, err := json.MarshalIndent(description, "", " ")
if err != nil {
log.Fatalf("format description: %v", err)
}
fmt.Printf("Peripheral description:\n%s\n", formatted)
if controlID != "" {
if err := exerciseControl(client, description, controlID, json.RawMessage(rawValue)); err != nil {
log.Fatalf("exercise control %q: %v", controlID, err)
}
fmt.Fprintf(os.Stdout, "Control %q accepted.\n", controlID)
}
}
// withTimeout gives every boot-time exchange its own deadline. A missing board
// therefore reports the exact handshake stage that failed instead of consuming
// one shared timeout and obscuring which response was absent.
func withTimeout[T any](timeout time.Duration, operation func(context.Context) (T, error)) (T, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return operation(ctx)
}
func exerciseControl(client *roverd.FirmataClient, description roverd.PeripheralDescription, controlID string, rawValue json.RawMessage) error {
var selected *roverd.PeripheralControl
for index := range description.Controls {
if description.Controls[index].ID == controlID {
selected = &description.Controls[index]
break
}
}
if selected == nil {
return errors.New("control is not present in the device description")
}
var value any
if err := json.Unmarshal(rawValue, &value); err != nil {
return fmt.Errorf("parse -value as JSON: %w", err)
}
// Standard outputs deliberately use standard Firmata commands. Only custom
// callbacks use the rover-peripheral CONTROL operation, which is the central
// distinction the probe is intended to validate on real hardware.
switch selected.Output.Type {
case "custom":
return client.SendPeripheralControl(selected.ID, value)
case "digital":
enabled, ok := value.(bool)
if !ok {
return errors.New("digital control value must be true or false")
}
if selected.Output.ActiveLow {
enabled = !enabled
}
if err := client.SetPinMode(byte(*selected.Output.Pin), roverd.FirmataPinModeOutput); err != nil {
return err
}
return client.SetDigitalPin(byte(*selected.Output.Pin), enabled)
case "pwm", "servo":
number, ok := value.(float64)
if !ok || number != float64(int(number)) {
return errors.New("PWM and servo control values must be whole numbers")
}
mode := roverd.FirmataPinModePWM
if selected.Output.Type == "servo" {
mode = roverd.FirmataPinModeServo
}
if err := client.SetPinMode(byte(*selected.Output.Pin), mode); err != nil {
return err
}
return client.ExtendedAnalog(byte(*selected.Output.Pin), int(number))
default:
return fmt.Errorf("unsupported output %q", selected.Output.Type)
}
}
+49 -24
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
@@ -29,6 +30,7 @@ func main() {
defer cancel()
logger := log.New(os.Stdout, "roverd: ", log.LstdFlags|log.Lmicroseconds|log.LUTC)
console := roverd.NewConsoleNotifier(logger)
serialPort, err := roverd.OpenSerial(cfg.Serial)
if err != nil {
@@ -36,6 +38,19 @@ func main() {
}
defer serialPort.Close()
// Peripheral discovery is intentionally a boot-time operation. The manager
// keeps successful USB ports open across server WebSocket reconnects and is
// rebuilt only when the roverd process itself restarts.
peripherals, err := roverd.DiscoverPeripheralManager(ctx, cfg.Serial.Device, logger)
if err != nil {
console.Notify(fmt.Sprintf("Rover peripheral startup failed: %v", err))
logger.Fatalf("discover rover peripherals: %v", err)
}
defer peripherals.Close()
for _, message := range peripherals.StartupBroadcasts() {
console.Notify(message)
}
var pulser *roverd.BRCPulser
if cfg.BRC.Enabled() {
pulser, err = roverd.NewBRCPulser(cfg.BRC, logger)
@@ -60,37 +75,47 @@ func main() {
mediaSupervisor.Start(ctx)
}
var cameraServo *roverd.CameraServo
if cfg.CameraServo.Enabled {
cameraServo, err = roverd.NewCameraServo(cfg.CameraServo, logger)
if err != nil {
logger.Fatalf("init camera servo: %v", err)
}
defer cameraServo.Close()
// Backend selection is identical on Pi and laptop hosts: enabled native
// GPIO wins, otherwise a discovered ESP32 may provide the built-in role.
hardwareControllers, err := roverd.ResolveRoverHardwareControllers(cfg, peripherals, logger)
if err != nil {
console.Notify(fmt.Sprintf("Rover peripheral startup failed while selecting hardware: %v", err))
logger.Fatalf("resolve rover hardware controllers: %v", err)
}
defer hardwareControllers.Close()
for _, message := range hardwareControllers.StartupBroadcasts() {
console.Notify(message)
}
var headlight *roverd.GPIOToggle
if cfg.Headlight.Enabled {
headlight, err = roverd.NewGPIOToggle("headlight", cfg.Headlight, logger)
if err != nil {
logger.Fatalf("init headlight: %v", err)
// A peripheral is never hot-reconnected. Report the first terminal serial
// failure for each discovered board and tell the local operator exactly what
// recovery action the fixed boot-time lifecycle requires.
go func() {
for {
select {
case failure := <-peripherals.Failures():
console.Notify(fmt.Sprintf(
"Rover peripheral %q (%s) disconnected: %v. Reconnect it and restart roverd.",
failure.Name,
failure.ID,
failure.Err,
))
case <-ctx.Done():
return
}
}
defer headlight.Close()
}
var laser *roverd.GPIOToggle
if cfg.Laser.Enabled {
laser, err = roverd.NewGPIOToggle("laser", cfg.Laser, logger)
if err != nil {
logger.Fatalf("init laser: %v", err)
}
defer laser.Close()
}
}()
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, headlight, laser, logger)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, hardwareControllers.CameraServo, hardwareControllers.Headlight, hardwareControllers.Laser, peripherals, logger, console)
// Startup is announced only after every configured hardware dependency has
// initialized successfully. A message here therefore means the control loop
// is genuinely ready, rather than merely that systemd launched the process.
console.Notify("roverd started and hardware initialization completed.")
defer console.Notify("roverd stopped.")
retryDelay := time.Second
for ctx.Err() == nil {
+23 -13
View File
@@ -1,19 +1,22 @@
package roverd
import "encoding/json"
type helloMessage struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
Headlight GPIOToggleConfig `json:"headlight"`
Laser GPIOToggleConfig `json:"laser"`
Private PrivateConfig `json:"private"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
Headlight GPIOToggleConfig `json:"headlight"`
Laser GPIOToggleConfig `json:"laser"`
Peripherals []RoverPeripheralMetadata `json:"peripherals,omitempty"`
Private PrivateConfig `json:"private"`
}
type sensorMessage struct {
@@ -45,6 +48,7 @@ type inboundMessage struct {
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
Headlight *togglePayload `json:"headlight,omitempty"`
Laser *togglePayload `json:"laser,omitempty"`
Peripheral *peripheralPayload `json:"peripheral,omitempty"`
Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
// Update is intentionally just a marker payload. The server can request the
@@ -103,6 +107,12 @@ type togglePayload struct {
Action string `json:"action"`
}
type peripheralPayload struct {
ID string `json:"id"`
Control string `json:"control"`
Value json.RawMessage `json:"value"`
}
type songPayload struct {
Slot *int `json:"slot,omitempty"`
Notes []songNote `json:"notes"`
+67
View File
@@ -0,0 +1,67 @@
package roverd
import (
"fmt"
"log"
"os"
"sync"
"time"
)
const roverConsolePath = "/dev/tty1"
// ConsoleNotifier writes the small set of rover lifecycle events that must be
// visible even when nobody is logged in. This intentionally targets tty1
// directly instead of using wall: wall discovers recipients through utmp, so
// it does not reliably reach a virtual console that is only showing a login
// prompt.
type ConsoleNotifier struct {
path string
logger *log.Logger
mu sync.Mutex
}
// NewConsoleNotifier returns the production notifier for the rover's primary
// local virtual console. Keeping the path inside the notifier also gives tests
// a way to substitute a regular temporary file without touching a real TTY.
func NewConsoleNotifier(logger *log.Logger) *ConsoleNotifier {
return newConsoleNotifier(roverConsolePath, logger)
}
func newConsoleNotifier(path string, logger *log.Logger) *ConsoleNotifier {
return &ConsoleNotifier{path: path, logger: logger}
}
// Notify appends one self-contained alert to the console. Console output is a
// diagnostic convenience rather than part of rover control, so an unavailable
// tty is logged but never allowed to stop startup, reconnection, docking, or
// reboot behavior.
func (n *ConsoleNotifier) Notify(message string) {
if n == nil {
return
}
n.mu.Lock()
defer n.mu.Unlock()
console, err := os.OpenFile(n.path, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
n.logFailure("open", err)
return
}
defer console.Close()
// Leading and trailing CRLFs keep the alert separate from an agetty login
// prompt, while plain text avoids leaving an unknown terminal in a modified
// color or cursor state.
timestamp := time.Now().UTC().Format("2006-01-02 15:04:05 UTC")
if _, err := fmt.Fprintf(console, "\r\n*** rover alert - %s ***\r\n%s\r\n", timestamp, message); err != nil {
n.logFailure("write", err)
}
}
func (n *ConsoleNotifier) logFailure(operation string, err error) {
if n.logger != nil {
n.logger.Printf("console notification %s failed for %s: %v", operation, n.path, err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package roverd
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func TestConsoleNotifierWritesVisibleAlert(t *testing.T) {
path := filepath.Join(t.TempDir(), "tty1")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatalf("create fake console: %v", err)
}
notifier := newConsoleNotifier(path, log.New(io.Discard, "", 0))
notifier.Notify("control server connection lost")
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fake console: %v", err)
}
output := string(contents)
if !strings.Contains(output, "*** rover alert - ") {
t.Fatalf("alert header missing from %q", output)
}
if !strings.Contains(output, "control server connection lost") {
t.Fatalf("alert message missing from %q", output)
}
}
func TestConsoleNotifierTreatsMissingConsoleAsNonfatal(t *testing.T) {
// A missing TTY is normal on some headless or containerized hosts. The
// contract is therefore simply that Notify returns instead of escalating a
// display failure into a rover-process failure.
notifier := newConsoleNotifier(filepath.Join(t.TempDir(), "missing"), log.New(io.Discard, "", 0))
notifier.Notify("roverd started")
}
+664
View File
@@ -0,0 +1,664 @@
package roverd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sync"
)
// Firmata command and mode constants are kept here instead of scattering raw
// bytes through the peripheral code. The values come directly from the Firmata
// protocol, so captures from a rover can be compared with the specification.
const (
firmataReportVersion byte = 0xF9
firmataSetPinMode byte = 0xF4
firmataSetDigitalPin byte = 0xF5
firmataStartSysex byte = 0xF0
firmataEndSysex byte = 0xF7
firmataReportFirmware byte = 0x79
firmataCapabilityQuery byte = 0x6B
firmataCapabilityReply byte = 0x6C
firmataExtendedAnalog byte = 0x6F
firmataServoConfig byte = 0x70
firmataPeripheralFeature byte = 0x01
firmataPeripheralDescribe byte = 0x00
firmataPeripheralDescription byte = 0x01
firmataPeripheralControl byte = 0x02
firmataMaximumSysexDataBytes = 252
FirmataPinModeOutput byte = 0x01
FirmataPinModePWM byte = 0x03
FirmataPinModeServo byte = 0x04
)
// FirmataMessage is the transport-neutral result of parsing one complete
// Firmata message. For SysEx messages Command is the SysEx feature byte and
// Data is everything between that feature byte and END_SYSEX.
type FirmataMessage struct {
Command byte
Data []byte
Sysex bool
}
// FirmataParser incrementally parses a byte stream. USB serial reads may split
// a message anywhere or combine several messages, so parsing whole Read calls
// as though they were packets would intermittently corrupt valid traffic.
type FirmataParser struct {
inSysex bool
sysex []byte
command byte
data []byte
expected int
}
// Feed accepts any fragment of the serial stream and returns every complete
// message found in it, preserving wire order.
func (p *FirmataParser) Feed(fragment []byte) ([]FirmataMessage, error) {
var messages []FirmataMessage
for _, value := range fragment {
if p.inSysex {
switch {
case value == firmataEndSysex:
if len(p.sysex) == 0 {
p.resetSysex()
return messages, errors.New("Firmata SysEx message is missing a feature byte")
}
messages = append(messages, FirmataMessage{
Command: p.sysex[0],
Data: append([]byte(nil), p.sysex[1:]...),
Sysex: true,
})
p.resetSysex()
case value&0x80 != 0:
// Bytes inside SysEx must be seven-bit clean. Reset immediately so
// a damaged frame cannot consume every later message on the port.
p.resetSysex()
return messages, fmt.Errorf("invalid 8-bit value 0x%02x inside Firmata SysEx", value)
default:
p.sysex = append(p.sysex, value)
}
continue
}
if value == firmataStartSysex {
p.inSysex = true
p.sysex = p.sysex[:0]
p.resetFixed()
continue
}
if value&0x80 != 0 {
p.command = value
p.data = p.data[:0]
p.expected = firmataDataLength(value)
if p.expected == 0 {
messages = append(messages, FirmataMessage{Command: value})
p.resetFixed()
}
continue
}
// Stray data before a status byte is harmless serial noise. Firmata
// has no framing information that could assign it to a command.
if p.expected == 0 {
continue
}
p.data = append(p.data, value)
if len(p.data) == p.expected {
messages = append(messages, FirmataMessage{
Command: p.command,
Data: append([]byte(nil), p.data...),
})
p.resetFixed()
}
}
return messages, nil
}
func (p *FirmataParser) resetSysex() {
p.inSysex = false
p.sysex = p.sysex[:0]
}
func (p *FirmataParser) resetFixed() {
p.command = 0
p.data = p.data[:0]
p.expected = 0
}
// firmataDataLength returns the number of seven-bit data bytes used by the
// fixed-length messages relevant to normal Firmata traffic. Unknown system
// commands are treated as single-byte messages so they cannot stall parsing of
// the rover-peripheral SysEx frames that follow them.
func firmataDataLength(command byte) int {
switch command {
case firmataReportVersion, firmataSetPinMode, firmataSetDigitalPin:
return 2
}
switch command & 0xF0 {
case 0x80, 0x90, 0xA0, 0xE0:
return 2
case 0xC0, 0xD0:
return 1
default:
return 0
}
}
// EncodeFirmata7Bit converts arbitrary bytes into the two-byte representation
// required inside Firmata SysEx. Keeping this transform below the JSON layer
// means firmware authors and UI code never need to think about wire encoding.
func EncodeFirmata7Bit(raw []byte) []byte {
encoded := make([]byte, 0, len(raw)*2)
for _, value := range raw {
encoded = append(encoded, value&0x7F, (value>>7)&0x01)
}
return encoded
}
// DecodeFirmata7Bit reverses EncodeFirmata7Bit and rejects malformed pairs.
func DecodeFirmata7Bit(encoded []byte) ([]byte, error) {
if len(encoded)%2 != 0 {
return nil, fmt.Errorf("Firmata 7-bit payload has odd length %d", len(encoded))
}
decoded := make([]byte, 0, len(encoded)/2)
for index := 0; index < len(encoded); index += 2 {
low, high := encoded[index], encoded[index+1]
if low&0x80 != 0 || high > 1 {
return nil, fmt.Errorf("invalid Firmata 7-bit pair at byte %d", index)
}
decoded = append(decoded, low|(high<<7))
}
return decoded, nil
}
// PeripheralDescription is generated by the ESP32 at boot. Controls is a slice
// intentionally: registration order is part of the UI contract and must never
// be replaced by map iteration or alphabetical sorting.
type PeripheralDescription struct {
Name string `json:"name"`
RoverControls PeripheralRoverControls `json:"roverControls,omitempty"`
Controls []PeripheralControl `json:"controls"`
}
type PeripheralRoverControls struct {
CameraServo *PeripheralCameraServo `json:"cameraServo,omitempty"`
Headlight *PeripheralDigitalRole `json:"headlight,omitempty"`
Laser *PeripheralDigitalRole `json:"laser,omitempty"`
}
type PeripheralCameraServo struct {
Pin int `json:"pin"`
MinimumAngleDegrees float64 `json:"minimumAngleDegrees"`
MaximumAngleDegrees float64 `json:"maximumAngleDegrees"`
HomeAngleDegrees float64 `json:"homeAngleDegrees"`
NudgeDegrees float64 `json:"nudgeDegrees"`
MinimumPulseMicroseconds int `json:"minimumPulseMicroseconds"`
MaximumPulseMicroseconds int `json:"maximumPulseMicroseconds"`
AllowRawPulse bool `json:"allowRawPulse"`
Inverted bool `json:"inverted"`
}
type PeripheralDigitalRole struct {
Pin int `json:"pin"`
ActiveLow bool `json:"activeLow"`
InitiallyOn bool `json:"initiallyOn"`
}
type PeripheralControl struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Mode string `json:"mode,omitempty"`
Minimum *int `json:"min,omitempty"`
Maximum *int `json:"max,omitempty"`
MaximumLength *int `json:"maxLength,omitempty"`
Output PeripheralOutput `json:"output"`
}
type PeripheralOutput struct {
Type string `json:"type"`
Pin *int `json:"pin,omitempty"`
ActiveLow bool `json:"activeLow,omitempty"`
}
// Validate catches authoring mistakes at connection time, where the error can
// name the offending peripheral, instead of allowing a malformed declaration
// to turn into a confusing no-op later when a driver uses the control.
func (description PeripheralDescription) Validate() error {
if description.Name == "" {
return errors.New("peripheral description requires a name")
}
if camera := description.RoverControls.CameraServo; camera != nil {
if err := validateFirmataPin("cameraServo", camera.Pin); err != nil {
return err
}
if camera.MinimumAngleDegrees >= camera.MaximumAngleDegrees {
return errors.New("cameraServo angle range must be increasing")
}
if camera.HomeAngleDegrees < camera.MinimumAngleDegrees || camera.HomeAngleDegrees > camera.MaximumAngleDegrees {
return errors.New("cameraServo home angle must be inside its angle range")
}
if camera.NudgeDegrees <= 0 {
return errors.New("cameraServo nudge must be positive")
}
if camera.MinimumPulseMicroseconds <= 0 || camera.MaximumPulseMicroseconds <= camera.MinimumPulseMicroseconds {
return errors.New("cameraServo pulse range must be positive and increasing")
}
}
if role := description.RoverControls.Headlight; role != nil {
if err := validateFirmataPin("headlight", role.Pin); err != nil {
return err
}
}
if role := description.RoverControls.Laser; role != nil {
if err := validateFirmataPin("laser", role.Pin); err != nil {
return err
}
}
seen := make(map[string]struct{}, len(description.Controls))
for index, control := range description.Controls {
if control.ID == "" || control.Name == "" {
return fmt.Errorf("control %d requires both id and name", index)
}
if _, exists := seen[control.ID]; exists {
return fmt.Errorf("control id %q is duplicated", control.ID)
}
seen[control.ID] = struct{}{}
switch control.Type {
case "slider", "number":
if control.Minimum == nil || control.Maximum == nil || *control.Minimum > *control.Maximum {
return fmt.Errorf("control %q requires a valid min and max", control.ID)
}
case "button":
if control.Mode != "toggle" && control.Mode != "momentary" {
return fmt.Errorf("button %q requires toggle or momentary mode", control.ID)
}
case "text":
if control.MaximumLength == nil || *control.MaximumLength <= 0 {
return fmt.Errorf("text control %q requires a positive maxLength", control.ID)
}
default:
return fmt.Errorf("control %q has unsupported type %q", control.ID, control.Type)
}
switch control.Output.Type {
case "digital":
if control.Output.Pin == nil {
return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type)
}
if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil {
return err
}
if control.Type != "button" {
return fmt.Errorf("digital output control %q must be a button", control.ID)
}
case "pwm", "servo":
if control.Output.Pin == nil {
return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type)
}
if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil {
return err
}
if control.Type != "slider" && control.Type != "number" {
return fmt.Errorf("%s output control %q must be a slider or number", control.Output.Type, control.ID)
}
case "custom":
default:
return fmt.Errorf("control %q has unsupported output %q", control.ID, control.Output.Type)
}
}
return nil
}
func validateFirmataPin(owner string, pin int) error {
// Firmata represents pin numbers with one seven-bit byte. Rejecting values
// outside that wire range avoids silently wrapping a declaration when it is
// converted to a byte for output commands.
if pin < 0 || pin > 127 {
return fmt.Errorf("%s pin must be between 0 and 127", owner)
}
return nil
}
// FirmataFirmware identifies the implementation answering the standard
// REPORT_FIRMWARE query. It is diagnostic metadata, not a protocol gate.
type FirmataFirmware struct {
Major int
Minor int
Name string
}
// FirmataPinCapability is one mode/resolution pair from CAPABILITY_RESPONSE.
type FirmataPinCapability struct {
Mode byte
Resolution byte
}
// FirmataClient owns one already-open serial connection. Its reader goroutine
// separates arbitrary USB read boundaries from request/response handling while
// writeMu prevents two commands from interleaving on the byte stream.
type FirmataClient struct {
connection io.ReadWriteCloser
parser FirmataParser
messages chan FirmataMessage
errors chan error
writeMu sync.Mutex
requestMu sync.Mutex
stateMu sync.RWMutex
terminalErr error
// terminalErrorHandler is invoked only for the first non-timeout read
// failure while the client context remains active. PeripheralManager uses it
// to turn an unexpected USB loss into one operator-facing broadcast.
terminalErrorHandler func(error)
}
func NewFirmataClient(connection io.ReadWriteCloser) *FirmataClient {
return &FirmataClient{
connection: connection,
messages: make(chan FirmataMessage, 16),
errors: make(chan error, 1),
}
}
// Start begins consuming the serial stream. The caller still owns the port and
// closes it during shutdown; this makes the client usable with both real serial
// ports and deterministic in-memory test connections.
func (client *FirmataClient) Start(ctx context.Context) {
go client.readLoop(ctx)
}
func (client *FirmataClient) readLoop(ctx context.Context) {
buffer := make([]byte, 256)
for {
count, err := client.connection.Read(buffer)
if count > 0 {
messages, parseErr := client.parser.Feed(buffer[:count])
if parseErr != nil {
client.publishError(ctx, parseErr)
return
}
for _, message := range messages {
select {
case client.messages <- message:
case <-ctx.Done():
return
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
// tarm/serial represents an ordinary ReadTimeout with io.EOF. A
// Firmata connection is expected to be quiet between commands, so
// treating that timeout as a closed device kills the reader before
// the next request can receive its reply. A real USB removal is
// reported by the serial driver as a non-EOF error.
select {
case <-ctx.Done():
return
default:
continue
}
}
client.publishError(ctx, err)
return
}
select {
case <-ctx.Done():
return
default:
}
}
}
func (client *FirmataClient) publishError(ctx context.Context, err error) {
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil && ctx.Err() == nil {
handler(err)
}
select {
case client.errors <- err:
case <-ctx.Done():
default:
}
}
func (client *FirmataClient) recordTerminalError(err error) (bool, func(error)) {
client.stateMu.Lock()
defer client.stateMu.Unlock()
firstTerminalError := client.terminalErr == nil
if client.terminalErr == nil {
client.terminalErr = err
}
handler := client.terminalErrorHandler
return firstTerminalError, handler
}
// SetTerminalErrorHandler registers the one-shot observer used after a device
// has completed discovery. If the connection already failed, the observer is
// called immediately so a narrow handshake-to-registration race is not lost.
func (client *FirmataClient) SetTerminalErrorHandler(handler func(error)) {
client.stateMu.Lock()
client.terminalErrorHandler = handler
terminalErr := client.terminalErr
client.stateMu.Unlock()
if terminalErr != nil && handler != nil {
handler(terminalErr)
}
}
func (client *FirmataClient) write(message []byte) error {
client.writeMu.Lock()
defer client.writeMu.Unlock()
client.stateMu.RLock()
terminalErr := client.terminalErr
client.stateMu.RUnlock()
if terminalErr != nil {
return fmt.Errorf("Firmata connection unavailable: %w", terminalErr)
}
written, err := client.connection.Write(message)
if err != nil {
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil {
handler(err)
}
return err
}
if written != len(message) {
err := fmt.Errorf("short Firmata write %d/%d", written, len(message))
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil {
handler(err)
}
return err
}
return nil
}
func (client *FirmataClient) writeSysex(command byte, data []byte) error {
message := make([]byte, 0, len(data)+3)
message = append(message, firmataStartSysex, command)
message = append(message, data...)
message = append(message, firmataEndSysex)
return client.write(message)
}
func (client *FirmataClient) waitFor(ctx context.Context, match func(FirmataMessage) bool) (FirmataMessage, error) {
for {
select {
case message := <-client.messages:
if match(message) {
return message, nil
}
case err := <-client.errors:
return FirmataMessage{}, err
case <-ctx.Done():
return FirmataMessage{}, ctx.Err()
}
}
}
func (client *FirmataClient) QueryFirmware(ctx context.Context) (FirmataFirmware, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataReportFirmware, nil); err != nil {
return FirmataFirmware{}, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataReportFirmware
})
if err != nil {
return FirmataFirmware{}, err
}
if len(message.Data) < 2 {
return FirmataFirmware{}, errors.New("Firmata firmware response is missing version bytes")
}
name, err := DecodeFirmata7Bit(message.Data[2:])
if err != nil {
return FirmataFirmware{}, fmt.Errorf("decode Firmata firmware name: %w", err)
}
return FirmataFirmware{Major: int(message.Data[0]), Minor: int(message.Data[1]), Name: string(name)}, nil
}
func (client *FirmataClient) QueryCapabilities(ctx context.Context) ([][]FirmataPinCapability, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataCapabilityQuery, nil); err != nil {
return nil, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataCapabilityReply
})
if err != nil {
return nil, err
}
return parseFirmataCapabilities(message.Data)
}
func parseFirmataCapabilities(data []byte) ([][]FirmataPinCapability, error) {
var pins [][]FirmataPinCapability
var pin []FirmataPinCapability
for index := 0; index < len(data); {
if data[index] == 0x7F {
pins = append(pins, pin)
pin = nil
index++
continue
}
if index+1 >= len(data) {
return nil, errors.New("Firmata capability response ends inside a mode pair")
}
pin = append(pin, FirmataPinCapability{Mode: data[index], Resolution: data[index+1]})
index += 2
}
if pin != nil {
return nil, errors.New("Firmata capability response is missing its final pin separator")
}
return pins, nil
}
func (client *FirmataClient) Describe(ctx context.Context) (PeripheralDescription, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataPeripheralFeature, []byte{firmataPeripheralDescribe}); err != nil {
return PeripheralDescription{}, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataPeripheralFeature && len(message.Data) > 0 && message.Data[0] == firmataPeripheralDescription
})
if err != nil {
return PeripheralDescription{}, err
}
raw, err := DecodeFirmata7Bit(message.Data[1:])
if err != nil {
return PeripheralDescription{}, fmt.Errorf("decode peripheral description: %w", err)
}
var description PeripheralDescription
if err := json.Unmarshal(raw, &description); err != nil {
return PeripheralDescription{}, fmt.Errorf("parse peripheral description: %w", err)
}
if err := description.Validate(); err != nil {
return PeripheralDescription{}, fmt.Errorf("validate peripheral description: %w", err)
}
return description, nil
}
func (client *FirmataClient) SetPinMode(pin, mode byte) error {
return client.write([]byte{firmataSetPinMode, pin & 0x7F, mode & 0x7F})
}
func (client *FirmataClient) SetDigitalPin(pin byte, enabled bool) error {
value := byte(0)
if enabled {
value = 1
}
return client.write([]byte{firmataSetDigitalPin, pin & 0x7F, value})
}
func (client *FirmataClient) ExtendedAnalog(pin byte, value int) error {
if value < 0 {
return fmt.Errorf("Firmata analog value cannot be negative: %d", value)
}
payload := []byte{pin & 0x7F}
// Firmata encodes integers as many seven-bit chunks as necessary. Zero
// still needs one value byte so the receiver can distinguish it from a
// message that contains only the pin.
for {
payload = append(payload, byte(value&0x7F))
value >>= 7
if value == 0 {
break
}
}
return client.writeSysex(firmataExtendedAnalog, payload)
}
func (client *FirmataClient) ConfigureServo(pin byte, minimumPulseMicroseconds, maximumPulseMicroseconds int) error {
if minimumPulseMicroseconds <= 0 || maximumPulseMicroseconds <= minimumPulseMicroseconds {
return errors.New("servo pulse range must be positive and increasing")
}
payload := []byte{
pin & 0x7F,
byte(minimumPulseMicroseconds & 0x7F), byte((minimumPulseMicroseconds >> 7) & 0x7F),
byte(maximumPulseMicroseconds & 0x7F), byte((maximumPulseMicroseconds >> 7) & 0x7F),
}
return client.writeSysex(firmataServoConfig, payload)
}
func (client *FirmataClient) SendPeripheralControl(controlID string, value any) error {
payload, err := json.Marshal(struct {
Control string `json:"control"`
Value any `json:"value"`
}{Control: controlID, Value: value})
if err != nil {
return fmt.Errorf("encode peripheral control: %w", err)
}
data := append([]byte{firmataPeripheralControl}, EncodeFirmata7Bit(payload)...)
// ConfigurableFirmata on ESP32 stores at most 252 bytes including the SysEx
// feature byte. Refuse a value that the board would otherwise discard as an
// incomplete frame; this is a transport constraint, not an application-level
// text policy.
if len(data)+1 > firmataMaximumSysexDataBytes {
return fmt.Errorf("peripheral control needs %d SysEx data bytes; Firmata accepts at most %d", len(data)+1, firmataMaximumSysexDataBytes)
}
return client.writeSysex(firmataPeripheralFeature, data)
}
+304
View File
@@ -0,0 +1,304 @@
package roverd
import (
"fmt"
"log"
"math"
"strings"
"sync"
"time"
)
// FirmataCameraServo preserves the established logical camera movement model
// while replacing only the final physical write. The ESP32 receives ordinary
// Firmata servo configuration and angle messages, regardless of rover host.
type FirmataCameraServo struct {
cfg CameraServoConfig
client *FirmataClient
pin byte
peripheralID string
mu sync.Mutex
currentAngle float64
desiredAngle float64
lastMove time.Time
moving bool
stopCh chan struct{}
closed bool
}
func newFirmataCameraServo(peripheral *managedPeripheral, declaration PeripheralCameraServo, logger *log.Logger) (*FirmataCameraServo, error) {
cfg := CameraServoConfig{
Enabled: true,
Pin: declaration.Pin,
FreqHz: 50,
CycleLen: 20000,
MinPulseUs: declaration.MinimumPulseMicroseconds,
MaxPulseUs: declaration.MaximumPulseMicroseconds,
MinAngle: declaration.MinimumAngleDegrees,
MaxAngle: declaration.MaximumAngleDegrees,
HomeAngle: declaration.HomeAngleDegrees,
NudgeDegrees: declaration.NudgeDegrees,
AllowRawPulse: declaration.AllowRawPulse,
Invert: declaration.Inverted,
}
servo := &FirmataCameraServo{
cfg: cfg,
client: peripheral.client,
pin: byte(declaration.Pin),
peripheralID: peripheral.metadata.ID,
stopCh: make(chan struct{}),
}
// SERVO_CONFIG establishes the peripheral-owned pulse calibration before
// selecting servo mode. This is standard Firmata, not a rover extension.
if err := servo.client.ConfigureServo(servo.pin, cfg.MinPulseUs, cfg.MaxPulseUs); err != nil {
return nil, fmt.Errorf("configure Firmata servo: %w", err)
}
if err := servo.client.SetPinMode(servo.pin, FirmataPinModeServo); err != nil {
return nil, fmt.Errorf("select Firmata servo mode: %w", err)
}
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
return nil, err
}
logger.Printf("camera servo using ESP32 %s pin %d (%.1f..%.1f deg)", peripheral.metadata.ID, declaration.Pin, cfg.MinAngle, cfg.MaxAngle)
return servo, nil
}
func (servo *FirmataCameraServo) SetAngle(angle float64) error {
servo.mu.Lock()
defer servo.mu.Unlock()
return servo.setAngleLocked(angle)
}
func (servo *FirmataCameraServo) setAngleLocked(angle float64) error {
if servo.closed {
return errorsNewControllerClosed("camera servo")
}
servo.desiredAngle = clampFloat(angle, servo.cfg.MinAngle, servo.cfg.MaxAngle)
limited := servo.rateLimitAngleLocked(servo.desiredAngle)
if err := servo.writeAngleLocked(limited); err != nil {
return err
}
servo.currentAngle = limited
if math.Abs(limited-servo.desiredAngle) > servoAngleEpsilon {
servo.startMoveLoopLocked()
}
return nil
}
func (servo *FirmataCameraServo) Nudge(delta float64) error {
servo.mu.Lock()
defer servo.mu.Unlock()
if delta == 0 {
delta = servo.cfg.NudgeDegrees
}
return servo.setAngleLocked(servo.currentAngle + delta)
}
func (servo *FirmataCameraServo) SetPulseWidth(micros int) error {
servo.mu.Lock()
defer servo.mu.Unlock()
if !servo.cfg.AllowRawPulse {
return fmt.Errorf("raw pulse commands disabled")
}
if micros <= 0 {
return fmt.Errorf("pulse width must be > 0")
}
pulse := clampInt(micros, servo.cfg.MinPulseUs, servo.cfg.MaxPulseUs)
return servo.setAngleLocked(servo.pulseToAngle(pulse))
}
func (servo *FirmataCameraServo) CurrentAngle() float64 {
servo.mu.Lock()
defer servo.mu.Unlock()
return servo.currentAngle
}
func (servo *FirmataCameraServo) Configuration() CameraServoConfig {
return servo.cfg
}
func (servo *FirmataCameraServo) BackendDescription() string {
return "ESP32 " + servo.peripheralID
}
func (servo *FirmataCameraServo) Close() {
servo.mu.Lock()
defer servo.mu.Unlock()
if servo.closed {
return
}
// Returning home matches the native Pi implementation. Any write failure is
// ignored during shutdown because the serial connection may already be gone.
_ = servo.writeAngleLocked(servo.cfg.HomeAngle)
close(servo.stopCh)
servo.closed = true
}
func (servo *FirmataCameraServo) writeAngleLocked(angle float64) error {
rangeDegrees := servo.cfg.MaxAngle - servo.cfg.MinAngle
normalized := (angle - servo.cfg.MinAngle) / rangeDegrees
normalized = math.Max(0, math.Min(1, normalized))
if servo.cfg.Invert {
normalized = 1 - normalized
}
// Standard Firmata servo values are positions from 0 through 180. Pulse
// calibration was already supplied through SERVO_CONFIG above.
position := int(math.Round(normalized * 180))
return servo.client.ExtendedAnalog(servo.pin, position)
}
func (servo *FirmataCameraServo) pulseToAngle(pulse int) float64 {
normalized := float64(pulse-servo.cfg.MinPulseUs) / float64(servo.cfg.MaxPulseUs-servo.cfg.MinPulseUs)
if servo.cfg.Invert {
normalized = 1 - normalized
}
return servo.cfg.MinAngle + normalized*(servo.cfg.MaxAngle-servo.cfg.MinAngle)
}
func (servo *FirmataCameraServo) rateLimitAngleLocked(target float64) float64 {
now := time.Now()
if servo.lastMove.IsZero() {
servo.lastMove = now
}
elapsed := now.Sub(servo.lastMove).Seconds()
if elapsed > servoStepInterval.Seconds() {
elapsed = servoStepInterval.Seconds()
}
maximumDelta := maxServoDegPerSec * elapsed
delta := target - servo.currentAngle
if math.Abs(delta) <= maximumDelta {
servo.lastMove = now
return target
}
servo.lastMove = now
if delta > 0 {
return servo.currentAngle + maximumDelta
}
return servo.currentAngle - maximumDelta
}
func (servo *FirmataCameraServo) startMoveLoopLocked() {
if servo.moving || servo.closed {
return
}
servo.moving = true
go func() {
ticker := time.NewTicker(servoStepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
servo.mu.Lock()
if servo.closed || math.Abs(servo.currentAngle-servo.desiredAngle) <= servoAngleEpsilon {
servo.moving = false
servo.mu.Unlock()
return
}
limited := servo.rateLimitAngleLocked(servo.desiredAngle)
if err := servo.writeAngleLocked(limited); err != nil {
// A failed serial write makes further automatic steps pointless.
// The next user command returns the connection error normally.
servo.moving = false
servo.mu.Unlock()
return
}
servo.currentAngle = limited
servo.mu.Unlock()
case <-servo.stopCh:
return
}
}
}()
}
// FirmataToggle owns logical state exactly like GPIOToggle but sends the final
// electrical level through Firmata's standard digital-pin command.
type FirmataToggle struct {
cfg GPIOToggleConfig
name string
client *FirmataClient
pin byte
peripheralID string
mu sync.Mutex
on bool
closed bool
}
func newFirmataToggle(name string, peripheral *managedPeripheral, declaration PeripheralDigitalRole, logger *log.Logger) (*FirmataToggle, error) {
cfg := GPIOToggleConfig{Enabled: true, GPIOPin: declaration.Pin, InitialOn: declaration.InitiallyOn, ActiveLow: declaration.ActiveLow}
toggle := &FirmataToggle{
cfg: cfg,
name: name,
client: peripheral.client,
pin: byte(declaration.Pin),
peripheralID: peripheral.metadata.ID,
on: cfg.InitialOn,
}
if err := toggle.client.SetPinMode(toggle.pin, FirmataPinModeOutput); err != nil {
return nil, fmt.Errorf("select Firmata output mode: %w", err)
}
if err := toggle.writeLocked(toggle.on); err != nil {
return nil, fmt.Errorf("initialize Firmata output: %w", err)
}
logger.Printf("%s using ESP32 %s pin %d (initial=%v activeLow=%v)", name, peripheral.metadata.ID, declaration.Pin, cfg.InitialOn, cfg.ActiveLow)
return toggle, nil
}
func (toggle *FirmataToggle) HandleAction(action string) error {
toggle.mu.Lock()
defer toggle.mu.Unlock()
if toggle.closed {
return errorsNewControllerClosed(toggle.name)
}
switch strings.ToLower(strings.TrimSpace(action)) {
case "", "toggle":
return toggle.setLocked(!toggle.on)
case "on":
return toggle.setLocked(true)
case "off":
return toggle.setLocked(false)
default:
return fmt.Errorf("unknown action %q", action)
}
}
func (toggle *FirmataToggle) setLocked(on bool) error {
if err := toggle.writeLocked(on); err != nil {
return err
}
toggle.on = on
return nil
}
func (toggle *FirmataToggle) writeLocked(on bool) error {
physicalHigh := on
if toggle.cfg.ActiveLow {
physicalHigh = !physicalHigh
}
return toggle.client.SetDigitalPin(toggle.pin, physicalHigh)
}
func (toggle *FirmataToggle) On() bool {
toggle.mu.Lock()
defer toggle.mu.Unlock()
return toggle.on
}
func (toggle *FirmataToggle) Configuration() GPIOToggleConfig {
return toggle.cfg
}
func (toggle *FirmataToggle) BackendDescription() string {
return "ESP32 " + toggle.peripheralID
}
func (toggle *FirmataToggle) Close() {
toggle.mu.Lock()
defer toggle.mu.Unlock()
toggle.closed = true
}
func errorsNewControllerClosed(name string) error {
return fmt.Errorf("%s controller closed", name)
}
@@ -0,0 +1,163 @@
package roverd
import (
"bytes"
"context"
"log"
"testing"
)
func TestDisabledNativeRolesResolveToFirmataOnEveryHostBuild(t *testing.T) {
description := PeripheralDescription{
Name: "Rover GPIO",
RoverControls: PeripheralRoverControls{
CameraServo: &PeripheralCameraServo{
Pin: 14, MinimumAngleDegrees: -15, MaximumAngleDegrees: 30,
HomeAngleDegrees: 0, NudgeDegrees: 2,
MinimumPulseMicroseconds: 900, MaximumPulseMicroseconds: 2100,
},
Headlight: &PeripheralDigitalRole{Pin: 18, ActiveLow: true, InitiallyOn: true},
Laser: &PeripheralDigitalRole{Pin: 16, ActiveLow: false, InitiallyOn: false},
},
Controls: []PeripheralControl{},
}
connection := scriptedPeripheralConnection(t, description)
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/rover-gpio"}, map[string]*scriptedConnection{"/dev/rover-gpio": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
// All native entries are disabled, exactly as they can be on either a Pi or
// laptop rover. The shared resolver must therefore select every ESP32 role.
baseline := len(connection.Bytes())
controllers, err := ResolveRoverHardwareControllers(&Config{}, manager, discardLogger())
if err != nil {
t.Fatalf("resolve: %v", err)
}
defer controllers.Close()
if controllers.CameraServo == nil || controllers.Headlight == nil || controllers.Laser == nil {
t.Fatalf("missing Firmata controller: %#v", controllers)
}
if !controllers.CameraServo.Configuration().Enabled || !controllers.Headlight.Configuration().Enabled || !controllers.Laser.Configuration().Enabled {
t.Fatal("ESP32-backed roles were not advertised as enabled")
}
wantHardwareBroadcast := "Rover hardware ready: camera servo via ESP32 firmata-0, headlight via ESP32 firmata-0, laser via ESP32 firmata-0."
if messages := controllers.StartupBroadcasts(); len(messages) != 1 || messages[0] != wantHardwareBroadcast {
t.Fatalf("hardware broadcasts = %#v, want %q", messages, wantHardwareBroadcast)
}
// Initialization uses only standard Firmata: servo calibration and mode,
// followed by the home position and digital initial states. The active-low
// headlight starts logically on, so its physical output is low.
writes := connection.Bytes()[baseline:]
wantPrefix := []byte{
firmataStartSysex, firmataServoConfig, 14, 4, 7, 52, 16, firmataEndSysex,
firmataSetPinMode, 14, FirmataPinModeServo,
firmataStartSysex, firmataExtendedAnalog, 14, 60, firmataEndSysex,
firmataSetPinMode, 18, FirmataPinModeOutput,
firmataSetDigitalPin, 18, 0,
firmataSetPinMode, 16, FirmataPinModeOutput,
firmataSetDigitalPin, 16, 0,
}
if !bytes.Equal(writes, wantPrefix) {
t.Fatalf("initial controller bytes = %v, want %v", writes, wantPrefix)
}
baseline = len(connection.Bytes())
if err := controllers.Headlight.HandleAction("off"); err != nil {
t.Fatalf("turn headlight off: %v", err)
}
if controllers.Headlight.On() {
t.Fatal("headlight remained logically on")
}
// Active-low means logical off becomes a high electrical output.
if got, want := connection.Bytes()[baseline:], []byte{firmataSetDigitalPin, 18, 1}; !bytes.Equal(got, want) {
t.Fatalf("headlight bytes = %v, want %v", got, want)
}
}
func TestMissingNativeAndFirmataRolesRemainDisabled(t *testing.T) {
manager := &PeripheralManager{byID: make(map[string]*managedPeripheral)}
controllers, err := ResolveRoverHardwareControllers(&Config{}, manager, discardLogger())
if err != nil {
t.Fatalf("resolve: %v", err)
}
if controllers.CameraServo != nil || controllers.Headlight != nil || controllers.Laser != nil {
t.Fatalf("unexpected controllers without providers: %#v", controllers)
}
}
func TestEnabledNativeRolesWinEvenWithSeveralFirmataProviders(t *testing.T) {
roleDescription := PeripheralDescription{RoverControls: PeripheralRoverControls{
CameraServo: &PeripheralCameraServo{},
Headlight: &PeripheralDigitalRole{},
Laser: &PeripheralDigitalRole{},
}}
manager := &PeripheralManager{
byID: make(map[string]*managedPeripheral),
peripherals: []*managedPeripheral{
{metadata: RoverPeripheralMetadata{ID: "firmata-0"}, description: roleDescription},
{metadata: RoverPeripheralMetadata{ID: "firmata-1"}, description: roleDescription},
},
}
cfg := &Config{
CameraServo: CameraServoConfig{Enabled: true},
Headlight: GPIOToggleConfig{Enabled: true},
Laser: GPIOToggleConfig{Enabled: true},
}
nativeCamera := &testCameraServoController{cfg: cfg.CameraServo}
nativeToggles := map[string]*testToggleController{}
factories := nativeHardwareControllerFactories{
newCameraServo: func(_ CameraServoConfig, _ *log.Logger) (CameraServoController, error) {
return nativeCamera, nil
},
newToggle: func(name string, config GPIOToggleConfig, _ *log.Logger) (ToggleController, error) {
controller := &testToggleController{cfg: config}
nativeToggles[name] = controller
return controller, nil
},
}
// Duplicate Firmata declarations are irrelevant when native hardware wins;
// selection must neither fail nor initialize either ESP32 provider.
controllers, err := resolveRoverHardwareControllers(cfg, manager, discardLogger(), factories)
if err != nil {
t.Fatalf("resolve native precedence: %v", err)
}
if controllers.CameraServo != nativeCamera || controllers.Headlight != nativeToggles["headlight"] || controllers.Laser != nativeToggles["laser"] {
t.Fatal("resolver did not retain native controllers")
}
messages := controllers.StartupBroadcasts()
if len(messages) != 2 || messages[0] != "Ignored ESP32 camera servo, headlight, laser because native GPIO is enabled." || messages[1] != "Rover hardware ready: camera servo via native GPIO, headlight via native GPIO, laser via native GPIO." {
t.Fatalf("native precedence broadcasts = %#v", messages)
}
}
type testCameraServoController struct {
cfg CameraServoConfig
}
func (controller *testCameraServoController) SetAngle(float64) error { return nil }
func (controller *testCameraServoController) Nudge(float64) error { return nil }
func (controller *testCameraServoController) SetPulseWidth(int) error { return nil }
func (controller *testCameraServoController) CurrentAngle() float64 { return 0 }
func (controller *testCameraServoController) Configuration() CameraServoConfig { return controller.cfg }
func (controller *testCameraServoController) BackendDescription() string { return "native GPIO" }
func (controller *testCameraServoController) Close() {}
type testToggleController struct {
cfg GPIOToggleConfig
on bool
}
func (controller *testToggleController) HandleAction(string) error { return nil }
func (controller *testToggleController) On() bool { return controller.on }
func (controller *testToggleController) Configuration() GPIOToggleConfig { return controller.cfg }
func (controller *testToggleController) BackendDescription() string { return "native GPIO" }
func (controller *testToggleController) Close() {}
+441
View File
@@ -0,0 +1,441 @@
package roverd
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"sync"
"testing"
"time"
)
func TestFirmataParserHandlesFragmentedSysex(t *testing.T) {
parser := FirmataParser{}
first, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription, 1})
if err != nil {
t.Fatalf("first fragment: %v", err)
}
if len(first) != 0 {
t.Fatalf("first fragment unexpectedly produced %d messages", len(first))
}
second, err := parser.Feed([]byte{0, 2, 0, firmataEndSysex})
if err != nil {
t.Fatalf("second fragment: %v", err)
}
want := []FirmataMessage{{
Command: firmataPeripheralFeature,
Data: []byte{firmataPeripheralDescription, 1, 0, 2, 0},
Sysex: true,
}}
if !reflect.DeepEqual(second, want) {
t.Fatalf("messages = %#v, want %#v", second, want)
}
}
func TestFirmataParserReturnsSeveralMessagesFromOneRead(t *testing.T) {
parser := FirmataParser{}
messages, err := parser.Feed([]byte{
firmataReportVersion, 2, 5,
firmataStartSysex, firmataCapabilityReply, 0x01, 0x01, 0x7F, firmataEndSysex,
firmataSetDigitalPin, 18, 1,
})
if err != nil {
t.Fatalf("feed: %v", err)
}
if len(messages) != 3 {
t.Fatalf("got %d messages, want 3", len(messages))
}
if messages[0].Command != firmataReportVersion || messages[1].Command != firmataCapabilityReply || messages[2].Command != firmataSetDigitalPin {
t.Fatalf("commands were not preserved in wire order: %#v", messages)
}
}
func TestFirmataParserRejectsEightBitSysexDataAndRecovers(t *testing.T) {
parser := FirmataParser{}
if _, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, 0x80}); err == nil {
t.Fatal("expected invalid SysEx data to fail")
}
messages, err := parser.Feed([]byte{firmataReportVersion, 2, 5})
if err != nil {
t.Fatalf("feed after invalid SysEx: %v", err)
}
if len(messages) != 1 || messages[0].Command != firmataReportVersion {
t.Fatalf("parser did not recover: %#v", messages)
}
}
func TestFirmataSevenBitRoundTripIncludesUTF8(t *testing.T) {
raw := []byte(`{"name":"Café lights","value":255}`)
encoded := EncodeFirmata7Bit(raw)
for index, value := range encoded {
if value&0x80 != 0 {
t.Fatalf("encoded byte %d is not seven-bit clean: 0x%02x", index, value)
}
}
decoded, err := DecodeFirmata7Bit(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(decoded, raw) {
t.Fatalf("decoded %q, want %q", decoded, raw)
}
}
func TestDecodeFirmataSevenBitRejectsMalformedPairs(t *testing.T) {
for name, encoded := range map[string][]byte{
"odd length": {1},
"high byte": {1, 2},
"eight bit": {0x80, 0},
} {
t.Run(name, func(t *testing.T) {
if _, err := DecodeFirmata7Bit(encoded); err == nil {
t.Fatal("expected malformed pair to fail")
}
})
}
}
func TestPeripheralDescriptionPreservesControlOrder(t *testing.T) {
raw := []byte(`{
"name":"Test peripheral",
"controls":[
{"id":"servo","type":"slider","name":"Servo","min":0,"max":180,"output":{"type":"servo","pin":14}},
{"id":"lights","type":"slider","name":"Lights","min":0,"max":255,"output":{"type":"pwm","pin":18}},
{"id":"action","type":"button","name":"Action","mode":"momentary","output":{"type":"custom"}}
]
}`)
var description PeripheralDescription
if err := json.Unmarshal(raw, &description); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if err := description.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
want := []string{"servo", "lights", "action"}
for index, id := range want {
if description.Controls[index].ID != id {
t.Fatalf("control %d = %q, want %q", index, description.Controls[index].ID, id)
}
}
}
func TestPeripheralDescriptionRejectsInvalidDeclarations(t *testing.T) {
minimum, maximum, pin := 10, 1, 200
for name, description := range map[string]PeripheralDescription{
"duplicate id": {
Name: "device",
Controls: []PeripheralControl{
{ID: "same", Name: "First", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
{ID: "same", Name: "Second", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
},
},
"reversed range": {
Name: "device",
Controls: []PeripheralControl{{
ID: "level", Name: "Level", Type: "slider", Minimum: &minimum, Maximum: &maximum, Output: PeripheralOutput{Type: "custom"},
}},
},
"pin outside Firmata": {
Name: "device",
Controls: []PeripheralControl{{
ID: "switch", Name: "Switch", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "digital", Pin: &pin},
}},
},
} {
t.Run(name, func(t *testing.T) {
if err := description.Validate(); err == nil {
t.Fatal("expected invalid description to fail")
}
})
}
}
func TestParseFirmataCapabilities(t *testing.T) {
pins, err := parseFirmataCapabilities([]byte{
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
})
if err != nil {
t.Fatalf("parse capabilities: %v", err)
}
if len(pins) != 2 || len(pins[0]) != 2 || pins[1][1].Mode != FirmataPinModeServo {
t.Fatalf("unexpected capabilities: %#v", pins)
}
if _, err := parseFirmataCapabilities([]byte{FirmataPinModeOutput}); err == nil {
t.Fatal("expected incomplete capability pair to fail")
}
}
func TestFirmataClientWritesStandardCommands(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SetPinMode(14, FirmataPinModeServo); err != nil {
t.Fatalf("set pin mode: %v", err)
}
if err := client.ConfigureServo(14, 900, 2100); err != nil {
t.Fatalf("configure servo: %v", err)
}
if err := client.ExtendedAnalog(14, 180); err != nil {
t.Fatalf("extended analog: %v", err)
}
if err := client.SetDigitalPin(19, true); err != nil {
t.Fatalf("digital write: %v", err)
}
want := []byte{
firmataSetPinMode, 14, FirmataPinModeServo,
firmataStartSysex, firmataServoConfig, 14, 4, 7, 52, 16, firmataEndSysex,
firmataStartSysex, firmataExtendedAnalog, 14, 52, 1, firmataEndSysex,
firmataSetDigitalPin, 19, 1,
}
if got := connection.Bytes(); !bytes.Equal(got, want) {
t.Fatalf("wire bytes = %v, want %v", got, want)
}
}
func TestFirmataClientQueriesAndDecodesDescription(t *testing.T) {
descriptionJSON := []byte(`{"name":"Bench device","controls":[{"id":"go","type":"button","name":"Go","mode":"momentary","output":{"type":"custom"}}]}`)
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
description := append([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription}, EncodeFirmata7Bit(descriptionJSON)...)
description = append(description, firmataEndSysex)
connection := newScriptedConnection(
append(append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...), firmataEndSysex),
description,
)
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
firmware, err := client.QueryFirmware(ctx)
if err != nil {
t.Fatalf("query firmware: %v", err)
}
if firmware.Name != "RoverPeripheralFirmata" || firmware.Major != 1 || firmware.Minor != 0 {
t.Fatalf("unexpected firmware: %#v", firmware)
}
got, err := client.Describe(ctx)
if err != nil {
t.Fatalf("describe: %v", err)
}
if got.Name != "Bench device" || len(got.Controls) != 1 || got.Controls[0].ID != "go" {
t.Fatalf("unexpected description: %#v", got)
}
writes := connection.Bytes()
wantWrites := []byte{
firmataStartSysex, firmataReportFirmware, firmataEndSysex,
firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescribe, firmataEndSysex,
}
if !bytes.Equal(writes, wantWrites) {
t.Fatalf("queries = %v, want %v", writes, wantWrites)
}
}
func TestFirmataClientKeepsReadingAfterSerialTimeoutEOF(t *testing.T) {
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
response := append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...)
response = append(response, firmataEndSysex)
// tarm/serial returns io.EOF when its ReadTimeout expires without bytes.
// Reproducing that behavior before the response prevents this regression
// from being hidden by an in-memory reader that blocks indefinitely instead.
connection := newScriptedConnection(response)
connection.timeoutsBeforeRead = 1
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
firmware, err := client.QueryFirmware(ctx)
if err != nil {
t.Fatalf("query firmware after timeout: %v", err)
}
if firmware.Name != "RoverPeripheralFirmata" {
t.Fatalf("firmware name = %q", firmware.Name)
}
}
func TestFirmataClientEncodesCustomControl(t *testing.T) {
for name, testCase := range map[string]struct {
controlID string
value any
wantJSON string
}{
"button": {controlID: "specialAction", value: true, wantJSON: `{"control":"specialAction","value":true}`},
"text": {controlID: "displayText", value: "Café ready", wantJSON: `{"control":"displayText","value":"Café ready"}`},
} {
t.Run(name, func(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SendPeripheralControl(testCase.controlID, testCase.value); err != nil {
t.Fatalf("send control: %v", err)
}
wire := connection.Bytes()
if len(wire) < 5 || wire[0] != firmataStartSysex || wire[1] != firmataPeripheralFeature || wire[2] != firmataPeripheralControl || wire[len(wire)-1] != firmataEndSysex {
t.Fatalf("invalid control frame: %v", wire)
}
raw, err := DecodeFirmata7Bit(wire[3 : len(wire)-1])
if err != nil {
t.Fatalf("decode control: %v", err)
}
if string(raw) != testCase.wantJSON {
t.Fatalf("control JSON = %s, want %s", raw, testCase.wantJSON)
}
})
}
}
func TestFirmataClientQueriesCapabilities(t *testing.T) {
response := []byte{
firmataStartSysex, firmataCapabilityReply,
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
firmataEndSysex,
}
connection := newScriptedConnection(response)
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
pins, err := client.QueryCapabilities(ctx)
if err != nil {
t.Fatalf("query capabilities: %v", err)
}
if len(pins) != 2 || pins[0][1].Mode != FirmataPinModePWM || pins[1][1].Mode != FirmataPinModeServo {
t.Fatalf("unexpected capabilities: %#v", pins)
}
if want := []byte{firmataStartSysex, firmataCapabilityQuery, firmataEndSysex}; !bytes.Equal(connection.Bytes(), want) {
t.Fatalf("query bytes = %v, want %v", connection.Bytes(), want)
}
}
func TestFirmataClientRejectsControlTooLargeForFirmwareParser(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SendPeripheralControl("displayText", string(bytes.Repeat([]byte{'x'}, 200))); err == nil {
t.Fatal("expected oversized control to fail")
}
if len(connection.Bytes()) != 0 {
t.Fatalf("oversized control wrote bytes: %v", connection.Bytes())
}
}
// recordingConnection is deliberately minimal: write-focused tests should not
// need goroutines or a real serial device merely to inspect exact Firmata bytes.
type recordingConnection struct {
mu sync.Mutex
writes bytes.Buffer
closed bool
writeErr error
}
func (connection *recordingConnection) Read(_ []byte) (int, error) { return 0, io.EOF }
func (connection *recordingConnection) Write(data []byte) (int, error) {
connection.mu.Lock()
defer connection.mu.Unlock()
if connection.closed {
return 0, io.ErrClosedPipe
}
if connection.writeErr != nil {
return 0, connection.writeErr
}
return connection.writes.Write(data)
}
func (connection *recordingConnection) Close() error {
connection.mu.Lock()
defer connection.mu.Unlock()
connection.closed = true
return nil
}
func (connection *recordingConnection) Bytes() []byte {
connection.mu.Lock()
defer connection.mu.Unlock()
return append([]byte(nil), connection.writes.Bytes()...)
}
func (connection *recordingConnection) Closed() bool {
connection.mu.Lock()
defer connection.mu.Unlock()
return connection.closed
}
func (connection *recordingConnection) SetWriteError(err error) {
connection.mu.Lock()
defer connection.mu.Unlock()
connection.writeErr = err
}
// scriptedConnection releases one response after each client write. This
// mirrors request/response serial behavior and prevents a fast reader goroutine
// from publishing all scripted answers before the matching query is sent.
type scriptedConnection struct {
recordingConnection
responses chan []byte
reads chan []byte
timeoutsBeforeRead int
pendingRead []byte
closeOnce sync.Once
}
func newScriptedConnection(responses ...[]byte) *scriptedConnection {
connection := &scriptedConnection{
responses: make(chan []byte, len(responses)),
reads: make(chan []byte, len(responses)),
}
for _, response := range responses {
connection.responses <- append([]byte(nil), response...)
}
return connection
}
func (connection *scriptedConnection) Read(target []byte) (int, error) {
if connection.timeoutsBeforeRead > 0 {
connection.timeoutsBeforeRead--
return 0, io.EOF
}
if len(connection.pendingRead) == 0 {
response, ok := <-connection.reads
if !ok {
return 0, io.ErrClosedPipe
}
connection.pendingRead = response
}
written := copy(target, connection.pendingRead)
connection.pendingRead = connection.pendingRead[written:]
return written, nil
}
func (connection *scriptedConnection) Write(data []byte) (int, error) {
written, err := connection.recordingConnection.Write(data)
if err == nil {
select {
case response := <-connection.responses:
connection.reads <- response
default:
}
}
return written, err
}
func (connection *scriptedConnection) Close() error {
connection.closeOnce.Do(func() {
_ = connection.recordingConnection.Close()
close(connection.reads)
})
return nil
}
+9
View File
@@ -87,6 +87,15 @@ func (g *GPIOToggle) On() bool {
return g.on
}
// Configuration returns the native toggle behavior used in the rover hello.
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return g.cfg
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
func (g *GPIOToggle) setLocked(on bool) error {
// This is the only place a logical device state becomes an electrical GPIO
// value. Hardware that turns on when pulled low sets activeLow in roverd
+12 -4
View File
@@ -13,10 +13,10 @@ type GPIOToggle struct {
func NewGPIOToggle(name string, _ GPIOToggleConfig, _ *log.Logger) (*GPIOToggle, error) {
/*
A Debian laptop has no Raspberry Pi GPIO character-device contract for
headlights or lasers. Returning an error when enabled makes bad laptop
configs fail during startup instead of advertising controls that cannot
change any hardware.
A Debian laptop has no native Raspberry Pi GPIO contract. Returning an
error here catches an invalid native configuration; the shared resolver
selects an ESP32 Firmata toggle before this constructor when native GPIO
is disabled.
*/
return nil, fmt.Errorf("%s not supported in the debian-laptop build", name)
}
@@ -30,3 +30,11 @@ func (g *GPIOToggle) HandleAction(action string) error {
func (g *GPIOToggle) On() bool {
return false
}
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{}
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+8
View File
@@ -24,3 +24,11 @@ func (g *GPIOToggle) HandleAction(action string) error {
func (g *GPIOToggle) On() bool {
return false
}
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{}
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+164
View File
@@ -0,0 +1,164 @@
package roverd
import (
"fmt"
"log"
"strings"
"time"
)
// Both physical servo backends consume these exact motion constants. Keeping
// them in shared code prevents Pi PWM and ESP32 Firmata movement from drifting
// apart as either implementation evolves.
const (
maxServoDegPerSec = 60.0
servoStepInterval = 20 * time.Millisecond
servoAngleEpsilon = 0.01
)
// CameraServoController is the hardware-neutral camera-tilt contract used by
// WSClient. Native Pi PWM and ESP32 Firmata implementations expose identical
// logical behavior, so command handling never branches on the rover host type.
type CameraServoController interface {
SetAngle(angle float64) error
Nudge(delta float64) error
SetPulseWidth(micros int) error
CurrentAngle() float64
Configuration() CameraServoConfig
BackendDescription() string
Close()
}
// ToggleController keeps headlight and laser command/state behavior independent
// of whether the electrical write happens on native Pi GPIO or an ESP32 pin.
type ToggleController interface {
HandleAction(action string) error
On() bool
Configuration() GPIOToggleConfig
BackendDescription() string
Close()
}
// RoverHardwareControllers is the result of the single startup-time backend
// decision. Its effective configurations are derived from whichever backend
// won, making the normal rover hello accurate on both Pi and laptop hosts.
type RoverHardwareControllers struct {
CameraServo CameraServoController
Headlight ToggleController
Laser ToggleController
ignoredESP32Roles []string
}
// StartupBroadcasts returns short operator-facing messages. Detailed pin and
// protocol information remains in the journal; tty1 only explains which
// physical backend won and whether an advertised ESP32 role was ignored.
func (controllers RoverHardwareControllers) StartupBroadcasts() []string {
var messages []string
if len(controllers.ignoredESP32Roles) > 0 {
messages = append(messages, fmt.Sprintf(
"Ignored ESP32 %s because native GPIO is enabled.",
strings.Join(controllers.ignoredESP32Roles, ", "),
))
}
messages = append(messages, fmt.Sprintf(
"Rover hardware ready: camera servo via %s, headlight via %s, laser via %s.",
controllerBackend(controllers.CameraServo),
controllerBackend(controllers.Headlight),
controllerBackend(controllers.Laser),
))
return messages
}
func controllerBackend(controller interface{ BackendDescription() string }) string {
if controller == nil {
return "disabled"
}
return controller.BackendDescription()
}
type nativeHardwareControllerFactories struct {
newCameraServo func(CameraServoConfig, *log.Logger) (CameraServoController, error)
newToggle func(string, GPIOToggleConfig, *log.Logger) (ToggleController, error)
}
// ResolveRoverHardwareControllers applies one rule on every real rover build:
// enabled native GPIO wins, otherwise one discovered ESP32 may fill the role.
// The rule is intentionally not selected by GOARCH or the debian_laptop tag.
func ResolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager, logger *log.Logger) (RoverHardwareControllers, error) {
factories := nativeHardwareControllerFactories{
newCameraServo: func(config CameraServoConfig, logger *log.Logger) (CameraServoController, error) {
return NewCameraServo(config, logger)
},
newToggle: func(name string, config GPIOToggleConfig, logger *log.Logger) (ToggleController, error) {
return NewGPIOToggle(name, config, logger)
},
}
return resolveRoverHardwareControllers(cfg, peripherals, logger, factories)
}
func resolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager, logger *log.Logger, factories nativeHardwareControllerFactories) (RoverHardwareControllers, error) {
var controllers RoverHardwareControllers
var err error
// Record ignored declarations separately from selecting controllers so the
// same native-first decision can be explained on the local rover console.
if cfg.CameraServo.Enabled && peripherals.HasRoverRole("cameraServo") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "camera servo")
}
if cfg.Headlight.Enabled && peripherals.HasRoverRole("headlight") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "headlight")
}
if cfg.Laser.Enabled && peripherals.HasRoverRole("laser") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "laser")
}
controllers.CameraServo, err = resolveCameraServoController(cfg.CameraServo, peripherals, logger, factories.newCameraServo)
if err != nil {
return RoverHardwareControllers{}, fmt.Errorf("init camera servo: %w", err)
}
controllers.Headlight, err = resolveToggleController("headlight", cfg.Headlight, peripherals, logger, factories.newToggle)
if err != nil {
controllers.Close()
return RoverHardwareControllers{}, fmt.Errorf("init headlight: %w", err)
}
controllers.Laser, err = resolveToggleController("laser", cfg.Laser, peripherals, logger, factories.newToggle)
if err != nil {
controllers.Close()
return RoverHardwareControllers{}, fmt.Errorf("init laser: %w", err)
}
return controllers, nil
}
func resolveCameraServoController(nativeConfig CameraServoConfig, peripherals *PeripheralManager, logger *log.Logger, newNative func(CameraServoConfig, *log.Logger) (CameraServoController, error)) (CameraServoController, error) {
if nativeConfig.Enabled {
if peripherals.HasRoverRole("cameraServo") {
logger.Printf("ignoring ESP32 cameraServo because native camera servo is enabled")
}
return newNative(nativeConfig, logger)
}
return peripherals.NewFirmataCameraServo(logger)
}
func resolveToggleController(name string, nativeConfig GPIOToggleConfig, peripherals *PeripheralManager, logger *log.Logger, newNative func(string, GPIOToggleConfig, *log.Logger) (ToggleController, error)) (ToggleController, error) {
if nativeConfig.Enabled {
if peripherals.HasRoverRole(name) {
logger.Printf("ignoring ESP32 %s because native %s is enabled", name, name)
}
return newNative(name, nativeConfig, logger)
}
return peripherals.NewFirmataToggle(name, logger)
}
// Close releases selected controller resources in reverse dependency order.
// Firmata controllers do not close the shared serial connection; that remains
// owned by PeripheralManager and is released by its separate shutdown defer.
func (controllers *RoverHardwareControllers) Close() {
if controllers.Laser != nil {
controllers.Laser.Close()
}
if controllers.Headlight != nil {
controllers.Headlight.Close()
}
if controllers.CameraServo != nil {
controllers.CameraServo.Close()
}
}
+57
View File
@@ -0,0 +1,57 @@
package roverd
import (
"encoding/json"
"strings"
"testing"
)
func TestHelloPeripheralMetadataContainsOnlyRenderableFields(t *testing.T) {
minimum, maximum := 0, 180
message := helloMessage{
Type: "hello",
Name: "test-rover",
Peripherals: []RoverPeripheralMetadata{{
ID: "firmata-0",
Name: "Camera arm",
Controls: []RoverPeripheralControl{{
ID: "position", Type: "slider", Name: "Position", Minimum: &minimum, Maximum: &maximum,
}},
}},
}
encoded, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal hello: %v", err)
}
text := string(encoded)
if !strings.Contains(text, `"peripherals":[{"id":"firmata-0","name":"Camera arm","controls":[{"id":"position","type":"slider","name":"Position","min":0,"max":180}]`) {
t.Fatalf("hello is missing ordered peripheral metadata: %s", text)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(encoded, &envelope); err != nil {
t.Fatalf("unmarshal hello envelope: %v", err)
}
peripheralJSON := string(envelope["peripherals"])
if strings.Contains(peripheralJSON, `"pin"`) || strings.Contains(peripheralJSON, `"output"`) {
t.Fatalf("hello exposed private Firmata routing: %s", peripheralJSON)
}
}
func TestInboundPeripheralCommandPreservesRawJSONValue(t *testing.T) {
var message inboundMessage
err := json.Unmarshal([]byte(`{
"type":"peripheral",
"id":"command-1",
"peripheral":{"id":"firmata-0","control":"displayText","value":"hello rover"}
}`), &message)
if err != nil {
t.Fatalf("unmarshal command: %v", err)
}
if message.Peripheral == nil || message.Peripheral.ID != "firmata-0" || message.Peripheral.Control != "displayText" {
t.Fatalf("unexpected peripheral command: %#v", message.Peripheral)
}
if string(message.Peripheral.Value) != `"hello rover"` {
t.Fatalf("raw value = %s", message.Peripheral.Value)
}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build dummy
package roverd
import (
"context"
"io"
"log"
"time"
)
// DiscoverPeripheralManager remains inert in a dummy build. The dummy daemon is
// specifically used without rover hardware and must not probe or reset serial
// devices that happen to be attached to a developer's machine.
func DiscoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger) (*PeripheralManager, error) {
dependencies := peripheralDiscoveryDependencies{
listCandidates: func(string) ([]string, error) { return nil, nil },
open: func(string) (io.ReadWriteCloser, error) { return nil, nil },
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: 0,
}
return discoverPeripheralManager(ctx, excludedDevice, logger, dependencies)
}
+38
View File
@@ -0,0 +1,38 @@
//go:build !dummy
package roverd
import (
"context"
"io"
"log"
"time"
"github.com/tarm/serial"
)
const (
peripheralBaud = 115200
peripheralReadTimeout = 100 * time.Millisecond
)
// DiscoverPeripheralManager performs the one and only peripheral scan for this
// roverd process. The Roomba Open Interface serial device is explicitly
// excluded because it belongs to SerialAdapter and must never be probed as an
// ESP32 peripheral.
func DiscoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger) (*PeripheralManager, error) {
dependencies := peripheralDiscoveryDependencies{
listCandidates: listPeripheralCandidates,
open: func(devicePath string) (io.ReadWriteCloser, error) {
return serial.OpenPort(&serial.Config{
Name: devicePath,
Baud: peripheralBaud,
ReadTimeout: peripheralReadTimeout,
})
},
sleep: time.Sleep,
startupWait: peripheralStartupWait,
handshakeWait: peripheralHandshakeTimeout,
}
return discoverPeripheralManager(ctx, excludedDevice, logger, dependencies)
}
+584
View File
@@ -0,0 +1,584 @@
package roverd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
peripheralStartupWait = 2 * time.Second
peripheralHandshakeTimeout = 5 * time.Second
peripheralFirmwareName = "RoverPeripheralFirmata"
)
// RoverPeripheralMetadata is the part of a peripheral description that leaves
// roverd. Pin numbers and output mappings intentionally remain private to the
// rover process; the server and browser identify only the declared control.
type RoverPeripheralMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Controls []RoverPeripheralControl `json:"controls"`
}
// RoverPeripheralControl contains only fields needed to render and operate one
// of the four generic UI controls. Pointer fields preserve legitimate zero
// bounds while still omitting properties that do not apply to a control type.
type RoverPeripheralControl struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Mode string `json:"mode,omitempty"`
Minimum *int `json:"min,omitempty"`
Maximum *int `json:"max,omitempty"`
MaximumLength *int `json:"maxLength,omitempty"`
}
type managedPeripheral struct {
metadata RoverPeripheralMetadata
description PeripheralDescription
controls map[string]PeripheralControl
client *FirmataClient
connection io.ReadWriteCloser
devicePath string
capabilities [][]FirmataPinCapability
}
// PeripheralManager owns the immutable boot-time inventory and every serial
// connection behind it. The inventory never changes after discovery, even if a
// USB device later disappears; a process restart is the only rescan mechanism.
type PeripheralManager struct {
mu sync.RWMutex
peripherals []*managedPeripheral
byID map[string]*managedPeripheral
cancel context.CancelFunc
closeOnce sync.Once
logger *log.Logger
failures chan PeripheralFailure
}
// PeripheralFailure is emitted once when a successfully discovered device's
// serial reader terminates unexpectedly. Device identity is retained even
// though reconnection still requires restarting roverd.
type PeripheralFailure struct {
ID string
Name string
Err error
}
type peripheralDiscoveryDependencies struct {
listCandidates func(excludedDevice string) ([]string, error)
open func(devicePath string) (io.ReadWriteCloser, error)
sleep func(time.Duration)
startupWait time.Duration
handshakeWait time.Duration
}
func discoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger, dependencies peripheralDiscoveryDependencies) (*PeripheralManager, error) {
managerContext, cancel := context.WithCancel(ctx)
manager := &PeripheralManager{
byID: make(map[string]*managedPeripheral),
cancel: cancel,
logger: logger,
failures: make(chan PeripheralFailure, 16),
}
candidates, err := dependencies.listCandidates(excludedDevice)
if err != nil {
manager.Close()
return nil, fmt.Errorf("list peripheral serial devices: %w", err)
}
for _, devicePath := range candidates {
connection, err := dependencies.open(devicePath)
if err != nil {
logger.Printf("skipping peripheral candidate %s: open failed: %v", devicePath, err)
continue
}
// UART bridge and native-USB development boards may reset when opened.
// Waiting and then draining boot fragments gives the handshake a fresh
// parser boundary instead of occasionally starting inside an old SysEx.
dependencies.sleep(dependencies.startupWait)
if err := drainPeripheralSerial(connection); err != nil {
connection.Close()
logger.Printf("skipping peripheral candidate %s: drain failed: %v", devicePath, err)
continue
}
client := NewFirmataClient(connection)
client.Start(managerContext)
firmware, err := queryPeripheralFirmware(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
logger.Printf("skipping peripheral candidate %s: Firmata query failed: %v", devicePath, err)
continue
}
if firmware.Name != peripheralFirmwareName {
connection.Close()
logger.Printf("skipping Firmata device %s: firmware %q does not expose rover peripherals", devicePath, firmware.Name)
continue
}
capabilities, err := queryPeripheralCapabilities(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("query capabilities from rover peripheral %s: %w", devicePath, err)
}
description, err := queryPeripheralDescription(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("describe rover peripheral %s: %w", devicePath, err)
}
peripheral := newManagedPeripheral(len(manager.peripherals), devicePath, connection, client, description, capabilities)
if err := peripheral.initializeStandardOutputs(); err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("initialize rover peripheral %s: %w", devicePath, err)
}
manager.peripherals = append(manager.peripherals, peripheral)
manager.byID[peripheral.metadata.ID] = peripheral
client.SetTerminalErrorHandler(func(terminalErr error) {
failure := PeripheralFailure{ID: peripheral.metadata.ID, Name: peripheral.metadata.Name, Err: terminalErr}
select {
case manager.failures <- failure:
default:
// The channel is intentionally bounded because broadcasts are
// diagnostic. Never block a Firmata reader during a fleet-wide
// shutdown or an unlikely burst of simultaneous USB failures.
logger.Printf("peripheral failure notification queue full for %s: %v", peripheral.metadata.ID, terminalErr)
}
})
logger.Printf("discovered rover peripheral %s on %s with %d generic controls", description.Name, devicePath, len(description.Controls))
}
return manager, nil
}
// StartupBroadcasts describes the fixed inventory without exposing device
// paths or wiring details on the rover's local console.
func (manager *PeripheralManager) StartupBroadcasts() []string {
inventory := manager.Inventory()
if len(inventory) == 0 {
return []string{"No ESP32 rover peripherals detected during startup."}
}
messages := make([]string, 0, len(inventory))
for _, peripheral := range inventory {
messages = append(messages, fmt.Sprintf(
"Rover peripheral %q connected as %s with %d additional controls.",
peripheral.Name,
peripheral.ID,
len(peripheral.Controls),
))
}
return messages
}
// Failures exposes unexpected runtime disconnects to the daemon entry point,
// which owns the ConsoleNotifier and therefore owns user-facing wording.
func (manager *PeripheralManager) Failures() <-chan PeripheralFailure {
if manager == nil {
return nil
}
return manager.failures
}
func listPeripheralCandidates(excludedDevice string) ([]string, error) {
patterns := []string{
"/dev/serial/by-id/*",
"/dev/ttyUSB*",
"/dev/ttyACM*",
}
var matchesInPreferenceOrder []string
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
sort.Strings(matches)
matchesInPreferenceOrder = append(matchesInPreferenceOrder, matches...)
}
return uniquePeripheralCandidates(matchesInPreferenceOrder, excludedDevice), nil
}
func uniquePeripheralCandidates(matches []string, excludedDevice string) []string {
excludedCanonical := canonicalDevicePath(excludedDevice)
seen := make(map[string]struct{})
var candidates []string
for _, match := range matches {
canonical := canonicalDevicePath(match)
if canonical == excludedCanonical {
continue
}
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
// /dev/serial/by-id matches are passed first, so retaining the first
// spelling favors stable names while still removing each tty alias.
candidates = append(candidates, match)
}
return candidates
}
func canonicalDevicePath(devicePath string) string {
if devicePath == "" {
return ""
}
resolved, err := filepath.EvalSymlinks(devicePath)
if err == nil {
return resolved
}
abs, err := filepath.Abs(devicePath)
if err == nil {
return filepath.Clean(abs)
}
return filepath.Clean(devicePath)
}
func drainPeripheralSerial(connection io.Reader) error {
buffer := make([]byte, 256)
for {
_, err := connection.Read(buffer)
if errors.Is(err, io.EOF) {
// tarm/serial uses EOF to mean its short read timeout elapsed. That
// quiet interval is precisely the boundary needed before handshaking.
return nil
}
if err != nil {
return err
}
}
}
func queryPeripheralFirmware(ctx context.Context, client *FirmataClient, timeout time.Duration) (FirmataFirmware, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.QueryFirmware(queryContext)
}
func queryPeripheralCapabilities(ctx context.Context, client *FirmataClient, timeout time.Duration) ([][]FirmataPinCapability, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.QueryCapabilities(queryContext)
}
func queryPeripheralDescription(ctx context.Context, client *FirmataClient, timeout time.Duration) (PeripheralDescription, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.Describe(queryContext)
}
func newManagedPeripheral(index int, devicePath string, connection io.ReadWriteCloser, client *FirmataClient, description PeripheralDescription, capabilities [][]FirmataPinCapability) *managedPeripheral {
controls := make(map[string]PeripheralControl, len(description.Controls))
metadataControls := make([]RoverPeripheralControl, 0, len(description.Controls))
for _, control := range description.Controls {
controls[control.ID] = control
metadataControls = append(metadataControls, RoverPeripheralControl{
ID: control.ID,
Type: control.Type,
Name: control.Name,
Mode: control.Mode,
Minimum: cloneIntPointer(control.Minimum),
Maximum: cloneIntPointer(control.Maximum),
MaximumLength: cloneIntPointer(control.MaximumLength),
})
}
return &managedPeripheral{
metadata: RoverPeripheralMetadata{
ID: fmt.Sprintf("firmata-%d", index),
Name: description.Name,
Controls: metadataControls,
},
description: description,
controls: controls,
client: client,
connection: connection,
devicePath: devicePath,
capabilities: capabilities,
}
}
func cloneIntPointer(value *int) *int {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func (peripheral *managedPeripheral) initializeStandardOutputs() error {
if camera := peripheral.description.RoverControls.CameraServo; camera != nil {
if err := peripheral.requirePinMode("cameraServo", camera.Pin, FirmataPinModeServo); err != nil {
return err
}
}
if headlight := peripheral.description.RoverControls.Headlight; headlight != nil {
if err := peripheral.requirePinMode("headlight", headlight.Pin, FirmataPinModeOutput); err != nil {
return err
}
}
if laser := peripheral.description.RoverControls.Laser; laser != nil {
if err := peripheral.requirePinMode("laser", laser.Pin, FirmataPinModeOutput); err != nil {
return err
}
}
for _, control := range peripheral.description.Controls {
if control.Output.Type == "custom" {
continue
}
pin := byte(*control.Output.Pin)
requiredMode := FirmataPinModeOutput
if control.Output.Type == "pwm" {
requiredMode = FirmataPinModePWM
} else if control.Output.Type == "servo" {
requiredMode = FirmataPinModeServo
}
if err := peripheral.requirePinMode("control "+control.ID, int(pin), requiredMode); err != nil {
return err
}
switch control.Output.Type {
case "digital":
if err := peripheral.client.SetPinMode(pin, FirmataPinModeOutput); err != nil {
return fmt.Errorf("configure control %q as digital: %w", control.ID, err)
}
// A generic button begins logically off. Active-low hardware needs a
// high electrical level to represent that same initial state.
if err := peripheral.client.SetDigitalPin(pin, control.Output.ActiveLow); err != nil {
return fmt.Errorf("initialize digital control %q: %w", control.ID, err)
}
case "pwm":
if err := peripheral.client.SetPinMode(pin, FirmataPinModePWM); err != nil {
return fmt.Errorf("configure control %q as PWM: %w", control.ID, err)
}
case "servo":
if err := peripheral.client.SetPinMode(pin, FirmataPinModeServo); err != nil {
return fmt.Errorf("configure control %q as servo: %w", control.ID, err)
}
}
}
return nil
}
func (peripheral *managedPeripheral) requirePinMode(owner string, pin int, requiredMode byte) error {
if pin < 0 || pin >= len(peripheral.capabilities) {
return fmt.Errorf("%s advertises pin %d, but Firmata reported only %d pins", owner, pin, len(peripheral.capabilities))
}
for _, capability := range peripheral.capabilities[pin] {
if capability.Mode == requiredMode {
return nil
}
}
return fmt.Errorf("%s advertises pin %d without required Firmata mode 0x%02x", owner, pin, requiredMode)
}
// HasRoverRole reports whether discovery found an ESP32 implementation of one
// established rover control. It is used only for startup selection and logging;
// commands continue to target the selected controller interface directly.
func (manager *PeripheralManager) HasRoverRole(role string) bool {
return len(manager.roverRoleProviders(role)) > 0
}
func (manager *PeripheralManager) roverRoleProviders(role string) []*managedPeripheral {
if manager == nil {
return nil
}
manager.mu.RLock()
defer manager.mu.RUnlock()
var providers []*managedPeripheral
for _, peripheral := range manager.peripherals {
switch role {
case "cameraServo":
if peripheral.description.RoverControls.CameraServo != nil {
providers = append(providers, peripheral)
}
case "headlight":
if peripheral.description.RoverControls.Headlight != nil {
providers = append(providers, peripheral)
}
case "laser":
if peripheral.description.RoverControls.Laser != nil {
providers = append(providers, peripheral)
}
}
}
return providers
}
// NewFirmataCameraServo constructs the shared camera controller only when a
// discovered peripheral declared that standardized role. Absence is a normal
// disabled-feature result rather than an error.
func (manager *PeripheralManager) NewFirmataCameraServo(logger *log.Logger) (CameraServoController, error) {
providers := manager.roverRoleProviders("cameraServo")
if len(providers) == 0 {
return nil, nil
}
if len(providers) > 1 {
return nil, duplicateRoverRoleError("cameraServo", providers)
}
peripheral := providers[0]
return newFirmataCameraServo(peripheral, *peripheral.description.RoverControls.CameraServo, logger)
}
// NewFirmataToggle resolves either standardized digital role without exposing
// the peripheral connection or ESP32 pin to WSClient.
func (manager *PeripheralManager) NewFirmataToggle(role string, logger *log.Logger) (ToggleController, error) {
providers := manager.roverRoleProviders(role)
if len(providers) == 0 {
return nil, nil
}
if len(providers) > 1 {
return nil, duplicateRoverRoleError(role, providers)
}
peripheral := providers[0]
var declaration *PeripheralDigitalRole
switch role {
case "headlight":
declaration = peripheral.description.RoverControls.Headlight
case "laser":
declaration = peripheral.description.RoverControls.Laser
default:
return nil, fmt.Errorf("unknown Firmata toggle role %q", role)
}
return newFirmataToggle(role, peripheral, *declaration, logger)
}
func duplicateRoverRoleError(role string, providers []*managedPeripheral) error {
providerIDs := make([]string, 0, len(providers))
for _, provider := range providers {
providerIDs = append(providerIDs, provider.metadata.ID)
}
return fmt.Errorf("rover peripheral role %s has multiple providers: %s", role, strings.Join(providerIDs, ", "))
}
// Inventory returns a defensive copy in startup order. Server reconnects reuse
// this same list and therefore never cause a USB rescan or ID reassignment.
func (manager *PeripheralManager) Inventory() []RoverPeripheralMetadata {
if manager == nil {
return nil
}
manager.mu.RLock()
defer manager.mu.RUnlock()
inventory := make([]RoverPeripheralMetadata, 0, len(manager.peripherals))
for _, peripheral := range manager.peripherals {
metadata := peripheral.metadata
metadata.Controls = make([]RoverPeripheralControl, 0, len(peripheral.metadata.Controls))
for _, control := range peripheral.metadata.Controls {
control.Minimum = cloneIntPointer(control.Minimum)
control.Maximum = cloneIntPointer(control.Maximum)
control.MaximumLength = cloneIntPointer(control.MaximumLength)
metadata.Controls = append(metadata.Controls, control)
}
inventory = append(inventory, metadata)
}
return inventory
}
// SetControl validates the browser-shaped value against the ESP32 declaration,
// then uses the private output mapping selected during startup. Neither the
// server nor browser can choose a pin or switch a custom control into raw GPIO.
func (manager *PeripheralManager) SetControl(peripheralID, controlID string, rawValue json.RawMessage) error {
if manager == nil {
return errors.New("rover peripherals disabled")
}
manager.mu.RLock()
peripheral := manager.byID[peripheralID]
manager.mu.RUnlock()
if peripheral == nil {
return fmt.Errorf("unknown peripheral %q", peripheralID)
}
control, exists := peripheral.controls[controlID]
if !exists {
return fmt.Errorf("unknown control %q on peripheral %q", controlID, peripheralID)
}
value, err := decodePeripheralControlValue(control, rawValue)
if err != nil {
return fmt.Errorf("control %q: %w", controlID, err)
}
switch control.Output.Type {
case "digital":
enabled := value.(bool)
if control.Output.ActiveLow {
enabled = !enabled
}
return peripheral.client.SetDigitalPin(byte(*control.Output.Pin), enabled)
case "pwm", "servo":
return peripheral.client.ExtendedAnalog(byte(*control.Output.Pin), value.(int))
case "custom":
return peripheral.client.SendPeripheralControl(control.ID, value)
default:
return fmt.Errorf("control has unsupported output %q", control.Output.Type)
}
}
func decodePeripheralControlValue(control PeripheralControl, rawValue json.RawMessage) (any, error) {
if len(rawValue) == 0 {
return nil, errors.New("value is required")
}
switch control.Type {
case "slider", "number":
var value int
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be a whole number")
}
if value < *control.Minimum || value > *control.Maximum {
return nil, fmt.Errorf("value must be between %d and %d", *control.Minimum, *control.Maximum)
}
return value, nil
case "button":
var value bool
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be true or false")
}
return value, nil
case "text":
var value string
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be text")
}
if utf8.RuneCountInString(value) > *control.MaximumLength {
return nil, fmt.Errorf("value must contain at most %d characters", *control.MaximumLength)
}
return value, nil
default:
return nil, fmt.Errorf("unsupported control type %q", control.Type)
}
}
// Close releases every discovered USB connection exactly once. It does not
// alter inventory or attempt to reconnect devices because shutdown/restart is
// the lifecycle boundary chosen for this feature.
func (manager *PeripheralManager) Close() {
if manager == nil {
return
}
manager.closeOnce.Do(func() {
manager.cancel()
manager.mu.Lock()
defer manager.mu.Unlock()
for _, peripheral := range manager.peripherals {
if err := peripheral.connection.Close(); err != nil {
manager.logger.Printf("close rover peripheral %s on %s: %v", peripheral.metadata.ID, peripheral.devicePath, err)
}
}
})
}
+425
View File
@@ -0,0 +1,425 @@
package roverd
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPeripheralManagerDiscoversInventoryAndDispatchesControls(t *testing.T) {
description := testPeripheralDescription("Bench accessory", false)
connection := scriptedPeripheralConnection(t, description)
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB9"},
map[string]*scriptedConnection{"/dev/ttyUSB9": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/ttyUSB0", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
inventory := manager.Inventory()
if len(inventory) != 1 {
t.Fatalf("inventory length = %d, want 1", len(inventory))
}
if inventory[0].ID != "firmata-0" || inventory[0].Name != "Bench accessory" {
t.Fatalf("unexpected peripheral metadata: %#v", inventory[0])
}
wantBroadcast := `Rover peripheral "Bench accessory" connected as firmata-0 with 3 additional controls.`
if broadcasts := manager.StartupBroadcasts(); len(broadcasts) != 1 || broadcasts[0] != wantBroadcast {
t.Fatalf("startup broadcasts = %#v, want %q", broadcasts, wantBroadcast)
}
wantOrder := []string{"servoPosition", "lightBrightness", "specialAction"}
for index, controlID := range wantOrder {
if inventory[0].Controls[index].ID != controlID {
t.Fatalf("control %d = %q, want %q", index, inventory[0].Controls[index].ID, controlID)
}
}
*inventory[0].Controls[0].Minimum = 99
if fresh := manager.Inventory(); *fresh[0].Controls[0].Minimum != 0 {
t.Fatal("caller mutation changed the manager's fixed inventory")
}
// Standard modes are configured once during discovery. Runtime slider
// commands should consequently contain only EXTENDED_ANALOG, not repeated
// mode changes that would detach and reattach a servo while it is moving.
baseline := len(connection.Bytes())
if err := manager.SetControl("firmata-0", "servoPosition", json.RawMessage(`90`)); err != nil {
t.Fatalf("set servo: %v", err)
}
servoWrite := connection.Bytes()[baseline:]
wantServo := []byte{firmataStartSysex, firmataExtendedAnalog, 13, 90, firmataEndSysex}
if !bytes.Equal(servoWrite, wantServo) {
t.Fatalf("servo bytes = %v, want %v", servoWrite, wantServo)
}
baseline = len(connection.Bytes())
if err := manager.SetControl("firmata-0", "lightBrightness", json.RawMessage(`128`)); err != nil {
t.Fatalf("set PWM: %v", err)
}
pwmWrite := connection.Bytes()[baseline:]
wantPWM := []byte{firmataStartSysex, firmataExtendedAnalog, 17, 0, 1, firmataEndSysex}
if !bytes.Equal(pwmWrite, wantPWM) {
t.Fatalf("PWM bytes = %v, want %v", pwmWrite, wantPWM)
}
baseline = len(connection.Bytes())
if err := manager.SetControl("firmata-0", "specialAction", json.RawMessage(`true`)); err != nil {
t.Fatalf("set custom button: %v", err)
}
customWrite := connection.Bytes()[baseline:]
if len(customWrite) < 5 || customWrite[1] != firmataPeripheralFeature || customWrite[2] != firmataPeripheralControl {
t.Fatalf("custom control did not use rover-peripheral SysEx: %v", customWrite)
}
}
func TestPeripheralManagerBroadcastsNoDevices(t *testing.T) {
manager := &PeripheralManager{byID: make(map[string]*managedPeripheral)}
want := "No ESP32 rover peripherals detected during startup."
if messages := manager.StartupBroadcasts(); len(messages) != 1 || messages[0] != want {
t.Fatalf("startup broadcasts = %#v, want %q", messages, want)
}
}
func TestPeripheralManagerReportsUnexpectedDisconnectOnce(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
// Closing the fake read stream models an unplugged USB serial adapter. The
// manager should publish one identified failure and never attempt reconnect.
_ = connection.Close()
select {
case failure := <-manager.Failures():
if failure.ID != "firmata-0" || failure.Name != "Bench accessory" || !errors.Is(failure.Err, io.ErrClosedPipe) {
t.Fatalf("unexpected failure: %#v", failure)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for peripheral disconnect")
}
select {
case duplicate := <-manager.Failures():
t.Fatalf("unexpected duplicate disconnect: %#v", duplicate)
case <-time.After(20 * time.Millisecond):
}
}
func TestPeripheralManagerRejectsInvalidValuesBeforeWriting(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
baseline := len(connection.Bytes())
invalid := []struct {
control string
value string
}{
{control: "servoPosition", value: `181`},
{control: "lightBrightness", value: `12.5`},
{control: "specialAction", value: `"yes"`},
}
for _, testCase := range invalid {
if err := manager.SetControl("firmata-0", testCase.control, json.RawMessage(testCase.value)); err == nil {
t.Fatalf("expected %s=%s to fail", testCase.control, testCase.value)
}
}
if got := len(connection.Bytes()); got != baseline {
t.Fatalf("invalid values wrote %d bytes", got-baseline)
}
}
func TestPeripheralManagerSkipsOtherFirmataFirmware(t *testing.T) {
other := newScriptedConnection(testFirmwareFrame("StandardFirmata"))
other.timeoutsBeforeRead = 1
rover := scriptedPeripheralConnection(t, testPeripheralDescription("Rover accessory", false))
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyACM0", "/dev/ttyUSB0"},
map[string]*scriptedConnection{
"/dev/ttyACM0": other,
"/dev/ttyUSB0": rover,
},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
if inventory := manager.Inventory(); len(inventory) != 1 || inventory[0].ID != "firmata-0" || inventory[0].Name != "Rover accessory" {
t.Fatalf("unexpected inventory: %#v", inventory)
}
if !other.Closed() {
t.Fatal("non-rover Firmata port was not closed")
}
}
func TestPeripheralManagerFailsMalformedRoverDescription(t *testing.T) {
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
testCapabilityFrame(),
testDescriptionFrame([]byte(`not-json`)),
)
connection.timeoutsBeforeRead = 1
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0"},
map[string]*scriptedConnection{"/dev/ttyUSB0": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err == nil || !strings.Contains(err.Error(), "describe rover peripheral") {
t.Fatalf("expected malformed description error, got manager=%v err=%v", manager, err)
}
if !connection.Closed() {
t.Fatal("malformed rover peripheral connection was not closed")
}
}
func TestPeripheralManagerRejectsAdvertisedUnsupportedPinMode(t *testing.T) {
description := testPeripheralDescription("Bad capability", false)
rawDescription, err := json.Marshal(description)
if err != nil {
t.Fatalf("marshal description: %v", err)
}
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
[]byte{
firmataStartSysex, firmataCapabilityReply,
FirmataPinModeOutput, 1, 0x7F,
firmataEndSysex,
},
testDescriptionFrame(rawDescription),
)
connection.timeoutsBeforeRead = 1
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0"},
map[string]*scriptedConnection{"/dev/ttyUSB0": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err == nil || !strings.Contains(err.Error(), "Firmata reported only 1 pins") {
t.Fatalf("expected unsupported capability error, got manager=%v err=%v", manager, err)
}
if !connection.Closed() {
t.Fatal("unsupported peripheral connection was not closed")
}
}
func TestPeripheralManagerRejectsDuplicateBuiltInProvidersWhenRoleIsSelected(t *testing.T) {
first := scriptedPeripheralConnection(t, testPeripheralDescription("First", true))
second := scriptedPeripheralConnection(t, testPeripheralDescription("Second", true))
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0", "/dev/ttyUSB1"},
map[string]*scriptedConnection{
"/dev/ttyUSB0": first,
"/dev/ttyUSB1": second,
},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discovery should retain providers until native precedence is known: %v", err)
}
defer manager.Close()
if _, err := manager.NewFirmataToggle("headlight", discardLogger()); err == nil || !strings.Contains(err.Error(), "role headlight has multiple providers") {
t.Fatalf("expected duplicate provider selection error, got %v", err)
}
if first.Closed() || second.Closed() {
t.Fatal("selection validation unexpectedly closed manager-owned ports")
}
}
func TestPeripheralManagerReturnsHardwareWriteFailure(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
connection.SetWriteError(errors.New("USB device removed"))
err = manager.SetControl("firmata-0", "lightBrightness", json.RawMessage(`128`))
if err == nil || !strings.Contains(err.Error(), "USB device removed") {
t.Fatalf("expected hardware error, got %v", err)
}
select {
case failure := <-manager.Failures():
if failure.ID != "firmata-0" || !strings.Contains(failure.Err.Error(), "USB device removed") {
t.Fatalf("unexpected write failure notification: %#v", failure)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for write failure notification")
}
}
func TestPeripheralManagerPassesRoombaDeviceToCandidateExclusion(t *testing.T) {
const roombaDevice = "/dev/serial/by-id/roomba-base"
listed := false
dependencies := peripheralDiscoveryDependencies{
listCandidates: func(excluded string) ([]string, error) {
listed = true
if excluded != roombaDevice {
t.Fatalf("excluded device = %q, want %q", excluded, roombaDevice)
}
return nil, nil
},
open: func(string) (io.ReadWriteCloser, error) { return nil, errors.New("unexpected open") },
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: time.Second,
}
manager, err := discoverPeripheralManager(context.Background(), roombaDevice, discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
manager.Close()
if !listed {
t.Fatal("candidate listing was not called")
}
}
func TestUniquePeripheralCandidatesPrefersStableAliasAndExcludesRoomba(t *testing.T) {
temporaryDirectory := t.TempDir()
peripheralTarget := filepath.Join(temporaryDirectory, "ttyUSB0")
roombaTarget := filepath.Join(temporaryDirectory, "ttyUSB1")
if err := os.WriteFile(peripheralTarget, nil, 0o600); err != nil {
t.Fatalf("create peripheral target: %v", err)
}
if err := os.WriteFile(roombaTarget, nil, 0o600); err != nil {
t.Fatalf("create Roomba target: %v", err)
}
stableAlias := filepath.Join(temporaryDirectory, "usb-rover-peripheral")
if err := os.Symlink(peripheralTarget, stableAlias); err != nil {
t.Fatalf("create stable alias: %v", err)
}
candidates := uniquePeripheralCandidates(
[]string{stableAlias, peripheralTarget, roombaTarget},
roombaTarget,
)
if len(candidates) != 1 || candidates[0] != stableAlias {
t.Fatalf("candidates = %v, want stable peripheral alias only", candidates)
}
}
func testPeripheralDiscoveryDependencies(paths []string, connections map[string]*scriptedConnection) peripheralDiscoveryDependencies {
return peripheralDiscoveryDependencies{
listCandidates: func(string) ([]string, error) {
return append([]string(nil), paths...), nil
},
open: func(devicePath string) (io.ReadWriteCloser, error) {
connection := connections[devicePath]
if connection == nil {
return nil, errors.New("test connection not found")
}
return connection, nil
},
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: time.Second,
}
}
func scriptedPeripheralConnection(t *testing.T, description PeripheralDescription) *scriptedConnection {
t.Helper()
rawDescription, err := json.Marshal(description)
if err != nil {
t.Fatalf("marshal description: %v", err)
}
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
testCapabilityFrame(),
testDescriptionFrame(rawDescription),
)
// The first read represents the quiet timeout used to drain boot output
// before the client's parser starts consuming explicit query responses.
connection.timeoutsBeforeRead = 1
return connection
}
func testPeripheralDescription(name string, provideHeadlight bool) PeripheralDescription {
minimumServo, maximumServo := 0, 180
minimumPWM, maximumPWM := 0, 255
servoPin, pwmPin := 13, 17
description := PeripheralDescription{
Name: name,
Controls: []PeripheralControl{
{
ID: "servoPosition", Type: "slider", Name: "Servo position",
Minimum: &minimumServo, Maximum: &maximumServo,
Output: PeripheralOutput{Type: "servo", Pin: &servoPin},
},
{
ID: "lightBrightness", Type: "slider", Name: "Light brightness",
Minimum: &minimumPWM, Maximum: &maximumPWM,
Output: PeripheralOutput{Type: "pwm", Pin: &pwmPin},
},
{
ID: "specialAction", Type: "button", Name: "Run special action", Mode: "momentary",
Output: PeripheralOutput{Type: "custom"},
},
},
}
if provideHeadlight {
description.RoverControls.Headlight = &PeripheralDigitalRole{Pin: 18}
}
return description
}
func testFirmwareFrame(name string) []byte {
frame := []byte{firmataStartSysex, firmataReportFirmware, 1, 0}
frame = append(frame, EncodeFirmata7Bit([]byte(name))...)
return append(frame, firmataEndSysex)
}
func testCapabilityFrame() []byte {
frame := []byte{firmataStartSysex, firmataCapabilityReply}
for pin := 0; pin < 40; pin++ {
// The test ESP32 reports the same three output modes as the reference
// firmware. Repeating real pin entries also exercises capability parsing
// independently of any particular example control pin.
frame = append(frame, FirmataPinModeOutput, 1, FirmataPinModePWM, 8, FirmataPinModeServo, 14, 0x7F)
}
return append(frame, firmataEndSysex)
}
func testDescriptionFrame(rawDescription []byte) []byte {
frame := []byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription}
frame = append(frame, EncodeFirmata7Bit(rawDescription)...)
return append(frame, firmataEndSysex)
}
func discardLogger() *log.Logger {
return log.New(io.Discard, "", 0)
}
+104 -11
View File
@@ -19,13 +19,18 @@ type WSClient struct {
sensorFrames <-chan []byte
events chan RoverEvent
media *MediaSupervisor
servo *CameraServo
servo CameraServoController
horn *HornSynth
headlight *GPIOToggle
laser *GPIOToggle
headlight ToggleController
laser ToggleController
peripherals *PeripheralManager
log *log.Logger
console *ConsoleNotifier
recoverMu sync.Mutex
recovering bool
watchdogMu sync.Mutex
watchdogOpen bool
watchdogOK bool
ttsQueue chan *ttsPayload
chromeTTS *chromeTTSDaemon
lastAux motorPWMPayload
@@ -41,7 +46,7 @@ type WSClient struct {
audioMu sync.RWMutex
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, headlight *GPIOToggle, laser *GPIOToggle, logger *log.Logger) *WSClient {
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo CameraServoController, headlight ToggleController, laser ToggleController, peripherals *PeripheralManager, logger *log.Logger, console *ConsoleNotifier) *WSClient {
var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
@@ -64,7 +69,9 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
horn: horn,
headlight: headlight,
laser: laser,
peripherals: peripherals,
log: logger,
console: console,
ttsQueue: ttsQueue,
chromeTTS: chromeTTS,
audioLevels: AudioLevels{
@@ -124,6 +131,20 @@ func (c *WSClient) Run(ctx context.Context) error {
}
func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
// Built-in metadata comes from the selected controller, not necessarily
// YAML. An ESP32 can enable a role whose native GPIO entry is disabled.
cameraServoConfig := CameraServoConfig{}
if c.servo != nil {
cameraServoConfig = c.servo.Configuration()
}
headlightConfig := GPIOToggleConfig{}
if c.headlight != nil {
headlightConfig = c.headlight.Configuration()
}
laserConfig := GPIOToggleConfig{}
if c.laser != nil {
laserConfig = c.laser.Configuration()
}
msg := helloMessage{
Type: "hello",
Name: c.cfg.Name,
@@ -132,11 +153,12 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
CameraServo: cameraServoConfig,
Audio: c.cfg.Audio,
Horn: c.cfg.Horn,
Headlight: c.cfg.Headlight,
Laser: c.cfg.Laser,
Headlight: headlightConfig,
Laser: laserConfig,
Peripherals: c.peripherals.Inventory(),
Private: c.cfg.Private,
}
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
@@ -233,6 +255,8 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleToggleCommand("headlight", c.headlight, msg.Headlight)
case msg.Laser != nil:
return c.handleToggleCommand("laser", c.laser, msg.Laser)
case msg.Peripheral != nil:
return c.peripherals.SetControl(msg.Peripheral.ID, msg.Peripheral.Control, msg.Peripheral.Value)
case msg.Song != nil:
slot := 0
if msg.Song.Slot != nil {
@@ -248,7 +272,7 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
}
}
func (c *WSClient) handleToggleCommand(name string, toggle *GPIOToggle, payload *togglePayload) error {
func (c *WSClient) handleToggleCommand(name string, toggle ToggleController, payload *togglePayload) error {
if toggle == nil {
return fmt.Errorf("%s disabled", name)
}
@@ -305,6 +329,7 @@ func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
go func() {
time.Sleep(delay)
c.console.Notify("Remote reboot requested. Rebooting the rover now.")
c.log.Printf("rebooting pi after remote reboot command")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
@@ -331,6 +356,7 @@ func (c *WSClient) handleUpdateCommand() error {
c.emitEvent("system.updateStarting", map[string]any{
"source": "remoteCommand",
})
c.console.Notify("Remote software update requested. roverd will restart if the update succeeds.")
// The helper is launched asynchronously because a successful update may
// restart roverd before this websocket command could stream progress back to
@@ -495,6 +521,10 @@ func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
lastRecovery = now
resetTimer()
case frame := <-c.sensorFrames:
// A real sensor frame is the authoritative end of a watchdog
// episode. Successfully sending the OI restart commands alone does
// not prove that the Roomba resumed producing sensor data.
c.closeSensorWatchdogEpisode()
lastFrame = time.Now()
resetTimer()
msg := sensorMessage{
@@ -641,6 +671,7 @@ func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
func (c *WSClient) markConnected() {
c.connMu.Lock()
wasConnected := c.connected
c.connected = true
c.seekIssued = false
c.rebootIssued = false
@@ -654,13 +685,19 @@ func (c *WSClient) markConnected() {
c.rebootT = nil
}
c.connMu.Unlock()
// Only print on a state transition. Run is retried indefinitely, and a
// message on every successful internal operation would quickly bury the
// useful lifecycle history at the login prompt.
if !wasConnected {
c.console.Notify("Control server connected.")
}
}
func (c *WSClient) markDisconnected() {
c.connMu.Lock()
if c.connected {
c.connected = false
}
wasConnected := c.connected
c.connected = false
if c.disconnectT == nil {
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
}
@@ -668,6 +705,13 @@ func (c *WSClient) markDisconnected() {
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
}
c.connMu.Unlock()
// Initial dial failures are already represented by the startup message and
// journal retry logs. The prominent disconnect alert is reserved for losing
// a connection that was actually established.
if wasConnected {
c.console.Notify("Control server connection lost. Automatic dock seek in 1 minute; rover reboot in 6 minutes if the connection is not restored.")
}
}
func (c *WSClient) handleDisconnectTimeout() {
@@ -679,6 +723,7 @@ func (c *WSClient) handleDisconnectTimeout() {
c.seekIssued = true
c.connMu.Unlock()
c.console.Notify("Control server has been disconnected for 1 minute. Seeking the dock now.")
if err := c.adapter.SeekDock(); err != nil {
c.log.Printf("seek dock on disconnect failed: %v", err)
return
@@ -695,6 +740,7 @@ func (c *WSClient) handleRebootTimeout() {
c.rebootIssued = true
c.connMu.Unlock()
c.console.Notify("Control server has been disconnected for 6 minutes. Rebooting the rover now.")
c.log.Printf("rebooting pi after prolonged websocket disconnect")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
@@ -720,10 +766,16 @@ func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Dura
c.emitEvent("sensorWatchdog.restart", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
if c.openSensorWatchdogEpisode() {
c.console.Notify(fmt.Sprintf("Sensor watchdog is restarting the Roomba sensor stream after %.1f seconds without data.", idleFor.Seconds()))
}
if err := c.adapter.StartOI(); err != nil {
c.log.Printf("watchdog start OI failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
// Unlike the restart notice, every concrete command failure is useful
// diagnostic information and may change between recovery attempts.
c.console.Notify(fmt.Sprintf("Sensor watchdog recovery failed while starting the Roomba OI: %v", err))
return
}
if cmdPause > 0 {
@@ -733,12 +785,53 @@ func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Dura
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
c.log.Printf("watchdog start stream failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
c.console.Notify(fmt.Sprintf("Sensor watchdog recovery failed while starting the sensor stream: %v", err))
return
}
c.emitEvent("sensorWatchdog.ok", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
if c.markSensorWatchdogCommandsOK() {
// Match the existing sensorWatchdog.ok contract precisely: this says
// the recovery commands succeeded, not that a new frame has arrived.
c.console.Notify("Sensor watchdog successfully sent the sensor-stream restart commands.")
}
}
// openSensorWatchdogEpisode reports whether this is the first recovery attempt
// since sensor frames stopped. The watchdog can retry every few seconds, so
// tracking the outage as one episode keeps the login console readable.
func (c *WSClient) openSensorWatchdogEpisode() bool {
c.watchdogMu.Lock()
defer c.watchdogMu.Unlock()
if c.watchdogOpen {
return false
}
c.watchdogOpen = true
c.watchdogOK = false
return true
}
// markSensorWatchdogCommandsOK suppresses duplicate success notices while the
// rover is still waiting for a real frame to close the current outage.
func (c *WSClient) markSensorWatchdogCommandsOK() bool {
c.watchdogMu.Lock()
defer c.watchdogMu.Unlock()
if c.watchdogOK {
return false
}
c.watchdogOK = true
return true
}
func (c *WSClient) closeSensorWatchdogEpisode() {
c.watchdogMu.Lock()
c.watchdogOpen = false
c.watchdogOK = false
c.watchdogMu.Unlock()
}
func isModeOpcode(op byte) bool {
+27
View File
@@ -0,0 +1,27 @@
package roverd
import "testing"
func TestSensorWatchdogConsoleEpisodeSuppressesDuplicateStatusMessages(t *testing.T) {
client := &WSClient{}
if !client.openSensorWatchdogEpisode() {
t.Fatal("first recovery attempt should announce the watchdog episode")
}
if client.openSensorWatchdogEpisode() {
t.Fatal("repeated recovery attempt should not repeat the outage announcement")
}
if !client.markSensorWatchdogCommandsOK() {
t.Fatal("first successful command restart should be announced")
}
if client.markSensorWatchdogCommandsOK() {
t.Fatal("repeated successful command restart should not be announced")
}
// Receiving a real frame closes the outage. A later silence is a distinct
// incident and must therefore be visible on the console again.
client.closeSensorWatchdogEpisode()
if !client.openSensorWatchdogEpisode() {
t.Fatal("new outage after a sensor frame should be announced")
}
}
+4
View File
@@ -6,6 +6,10 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
# roverd cannot report an unexpected exit after its process is already gone.
# ExecStopPost fills only that gap; ordinary lifecycle messages remain owned by
# roverd, and SERVICE_RESULT prevents clean stops from being labeled failures.
ExecStopPost=/bin/sh -c 'if [ "$SERVICE_RESULT" != "success" ]; then /usr/bin/printf "\r\n*** rover alert ***\r\nroverd exited unexpectedly; systemd will restart it.\r\n" > /dev/tty1 || true; fi'
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
+6 -1
View File
@@ -99,10 +99,15 @@ audioForward:
maxUploadBytes: 8388608
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
# 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
+3
View File
@@ -12,6 +12,9 @@ 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');
+20 -10
View File
@@ -11,8 +11,6 @@ CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
@@ -30,6 +28,8 @@ fi
TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR"
DATA_DIR="$SERVER_DIR/data"
SNAPSHOT_DIR="$DATA_DIR/rover-snapshots"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml"
@@ -281,10 +281,23 @@ rm -f "$MEDIAMTX_SERVICE"
rm -f /etc/mediamtx/mediamtx.yml
echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
# The repository data directory is the legacy deployment's single persistence
# root and becomes the one bind-mounted /data directory during containerization.
# Create only the snapshot child eagerly because MediaMTX's hook writes there;
# the other services already create their own children when those features run.
mkdir -p "$DATA_DIR" "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$DATA_DIR" "$SNAPSHOT_DIR"
# Previous installers used these two /var/lib directories. Replay code already
# stopped reading its old location, and snapshots regenerate immediately, so do
# not merge possibly stale runtime media over the new canonical data tree. Keep
# an existing directory untouched and report it for deliberate cleanup after the
# operator verifies the upgraded server.
for legacy_dir in /var/lib/rover-snapshots /var/lib/replay-segments; do
if [[ -d "$legacy_dir" ]]; then
echo " Legacy runtime directory is no longer used: $legacy_dir"
fi
done
cat > "$MULTIROVER_SERVICE" <<EOF
[Unit]
Description=Multi-Roomba Rover control server
@@ -297,11 +310,8 @@ 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
RuntimeDirectory=multirover
RuntimeDirectoryMode=0750
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
RestartSec=2
+10 -1
View File
@@ -5,7 +5,16 @@
set -euo pipefail
PATH_NAME="${MTX_PATH:-}"
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
# Node resolves and supplies SERVER_DATA_DIR when it starts MediaMTX, and
# MediaMTX carries that environment into this runOnReady hook. Requiring that
# single root prevents the writer from silently recreating the former /var/lib
# snapshot store while the readers are looking inside the mounted data folder.
if [[ -z "${SERVER_DATA_DIR:-}" ]]; then
echo "SERVER_DATA_DIR is required for rover snapshot output" >&2
exit 1
fi
SNAP_DIR="${SERVER_DATA_DIR}/rover-snapshots"
# Ignore non-rover-video paths.
case "$PATH_NAME" in
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-C7V6I437.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
<script type="module" crossorigin src="/assets/index-_6Wi5B-Z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
</head>
<body>
<div id="root"></div>
+30 -22
View File
@@ -1,41 +1,49 @@
// data Paths helper
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
const fs = require('fs');
// Purpose: Defines the single filesystem boundary for all mutable, persistent server data.
// Scope: Resolves the configured data root and every application-owned mutable path beneath it.
const path = require('path');
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
function pathExists(target) {
try {
fs.accessSync(target, fs.constants.F_OK);
return true;
} catch (_err) {
return false;
}
}
const ROVER_SNAPSHOT_DIR_NAME = 'rover-snapshots';
const RUNTIME_DIR_NAME = 'runtime';
function resolveDataDir() {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.resolve(configured);
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
return CANONICAL_DATA_DIR;
}
function resolveDataPath(fileName) {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.join(path.resolve(configured), fileName);
/*
Always join through resolveDataDir instead of repeating environment handling
in individual services. This is what makes one SERVER_DATA_DIR mount contain
every database, JSON store, generated file, and persistent media directory.
*/
return path.join(resolveDataDir(), fileName);
}
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
if (pathExists(canonicalPath)) return canonicalPath;
if (pathExists(legacyPath)) return legacyPath;
return canonicalPath;
function resolveRoverSnapshotDir() {
/*
Snapshot production, polling, PTZ reads, and health reporting must use the
exact same directory. Giving this shared directory a named resolver prevents
one of those consumers from drifting back to the former /var/lib location.
*/
return resolveDataPath(ROVER_SNAPSHOT_DIR_NAME);
}
function resolveRuntimePath(...pathSegments) {
/*
Disposable files are still files intentionally managed by the Node server.
Keeping them below a named runtime directory preserves the single-root
filesystem contract without confusing scratch files with durable stores.
Callers remain responsible for deleting their own completed work.
*/
return resolveDataPath(path.join(RUNTIME_DIR_NAME, ...pathSegments));
}
module.exports = {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
};
+49
View File
@@ -0,0 +1,49 @@
// Data Paths Helper Tests
// Purpose: Pins the one-root persistence contract used by local, systemd, and future container deployments.
// Scope: Exercises path resolution only and never creates files in the real server data directory.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('os');
const path = require('path');
const {
resolveDataDir,
resolveDataPath,
resolveRoverSnapshotDir,
resolveRuntimePath,
} = require('./dataPaths');
const originalDataDir = process.env.SERVER_DATA_DIR;
test.afterEach(() => {
/*
Environment state is process-global. Restore the caller's value after each
assertion so this focused test remains safe when it is composed with other
tests in the same Node process later.
*/
if (originalDataDir === undefined) delete process.env.SERVER_DATA_DIR;
else process.env.SERVER_DATA_DIR = originalDataDir;
});
test('defaults every persistent path to the canonical server data directory', () => {
delete process.env.SERVER_DATA_DIR;
const expectedRoot = path.resolve(__dirname, '..', '..', 'data');
assert.equal(resolveDataDir(), expectedRoot);
assert.equal(resolveDataPath('identity.sqlite'), path.join(expectedRoot, 'identity.sqlite'));
assert.equal(resolveRoverSnapshotDir(), path.join(expectedRoot, 'rover-snapshots'));
assert.equal(resolveRuntimePath('replay-builds'), path.join(expectedRoot, 'runtime', 'replay-builds'));
});
test('moves every persistent path beneath SERVER_DATA_DIR when it is configured', () => {
const configuredRoot = path.join(os.tmpdir(), 'multirover-data-path-test');
process.env.SERVER_DATA_DIR = configuredRoot;
assert.equal(resolveDataDir(), path.resolve(configuredRoot));
assert.equal(resolveDataPath('fleet-reports.sqlite'), path.join(configuredRoot, 'fleet-reports.sqlite'));
assert.equal(resolveDataPath(path.join('replays', 'example.mp4')), path.join(configuredRoot, 'replays', 'example.mp4'));
assert.equal(resolveRoverSnapshotDir(), path.join(configuredRoot, 'rover-snapshots'));
assert.equal(
resolveRuntimePath('audio-forward', 'uploads'),
path.join(configuredRoot, 'runtime', 'audio-forward', 'uploads'),
);
});
@@ -0,0 +1,75 @@
// Reward Definition: Green Mode
// Purpose: Enables the server-wide green theme and room effect for twenty minutes.
// Scope: Owns button-box timing/recovery while delegating the actual mode to greenModeService.
const DURATION_MS = 20 * 60 * 1000;
let activeTimer = null;
let unsubscribeGreenMode = null;
function clearRuntimeWatchers() {
if (activeTimer) {
clearTimeout(activeTimer);
activeTimer = null;
}
if (unsubscribeGreenMode) {
unsubscribeGreenMode();
unsubscribeGreenMode = null;
}
}
async function stopGreenMode(ctx) {
clearRuntimeWatchers();
await ctx.setGreenMode(false, { source: 'buttonbox:greenModeExpired' });
ctx.clearEffect('greenMode');
}
async function startGreenMode(ctx, effect = {}) {
clearRuntimeWatchers();
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now());
if (remaining <= 0) {
await stopGreenMode(ctx);
return;
}
await ctx.setGreenMode(true, { source: 'buttonbox:greenMode' });
ctx.saveEffect('greenMode', { endsAt });
/*
Access-mode changes disable green mode through greenModeService. Watching
that shared state transition lets the reward discard its persisted effect
immediately, so a restart cannot accidentally revive a reward that was
intentionally ended early.
*/
unsubscribeGreenMode = ctx.onGreenModeChange((enabled) => {
if (enabled) return;
clearRuntimeWatchers();
ctx.clearEffect('greenMode');
});
activeTimer = setTimeout(() => {
stopGreenMode(ctx).catch((err) => {
ctx.logger.warn('green mode reward stop failed', { error: err.message });
});
}, remaining);
}
module.exports = {
id: 'greenMode',
name: 'Green mode',
description: 'Makes the room and server green for 20 minutes.',
goal: 5,
async run(ctx) {
await startGreenMode(ctx, { endsAt: Date.now() + DURATION_MS });
},
async recover(ctx, effect) {
// Recovery must never manufacture a fresh twenty-minute window from a
// missing or corrupt persisted deadline. Treat it as expired and clean up.
if (!Number.isFinite(Number(effect?.endsAt))) {
await stopGreenMode(ctx);
return;
}
await startGreenMode(ctx, effect);
},
};
@@ -0,0 +1,57 @@
// Green Mode Reward Tests
// Purpose: Pins the five-press metadata and persisted timed-effect lifecycle.
// Scope: Uses a small context double; greenModeService behavior is tested through its public contract.
const test = require('node:test');
const assert = require('node:assert/strict');
const reward = require('./greenMode');
function createContext() {
const calls = [];
let changeListener = null;
return {
calls,
logger: { warn: () => {} },
setGreenMode: async (enabled, options) => {
calls.push({ type: 'set', enabled, source: options?.source });
return enabled;
},
saveEffect: (id, payload) => calls.push({ type: 'save', id, payload }),
clearEffect: (id) => calls.push({ type: 'clear', id }),
onGreenModeChange: (listener) => {
changeListener = listener;
return () => {
changeListener = null;
};
},
emitGreenModeChange: (enabled) => changeListener?.(enabled),
};
}
test('green mode reward requires five presses and starts a persisted effect', async () => {
const ctx = createContext();
assert.equal(reward.goal, 5);
await reward.run(ctx);
assert.deepEqual(ctx.calls[0], { type: 'set', enabled: true, source: 'buttonbox:greenMode' });
const saved = ctx.calls.find((call) => call.type === 'save');
assert.equal(saved?.id, 'greenMode');
assert.ok(saved?.payload?.endsAt > Date.now());
// Simulate an access-mode shutdown so the test also clears the reward's
// twenty-minute timer instead of leaving background work in the test process.
ctx.emitGreenModeChange(false);
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
test('invalid recovery state is cleared instead of starting a new duration', async () => {
const ctx = createContext();
await reward.recover(ctx, {});
assert.deepEqual(ctx.calls[0], {
type: 'set',
enabled: false,
source: 'buttonbox:greenModeExpired',
});
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
+2
View File
@@ -10,6 +10,7 @@ const discordPingEveryone = require('./definitions/discordPingEveryone');
const modeJam = require('./definitions/modeJam');
const assignmentRoulette = require('./definitions/assignmentRoulette');
const chatSpam = require('./definitions/chatSpam');
const greenMode = require('./definitions/greenMode');
const orderedRewards = [
dockPanic,
@@ -22,6 +23,7 @@ const orderedRewards = [
modeJam,
assignmentRoulette,
chatSpam,
greenMode,
];
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
+34 -30
View File
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('../modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const roverManager = require('../roverManager');
const { compareRoversForAssignment } = require('./roverRanking');
const socketRefs = new Map(); // socketId -> socket
const assignments = new Map(); // socketId -> roverId
@@ -96,8 +97,26 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
}
});
roverManager.managerEvents.on('rover', ({ action }) => {
if (action === 'removed' || action === 'upsert') {
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') {
/*
The physical rover record is the authority for current driver ownership.
Once it disappears, every assignment that names it must be released and
run through ordinary placement again. Leaving those map entries intact
lets the same id become visible after reconnect without recreating its
driver membership, which is the exact stale-UI/video-auth split this
lifecycle boundary must prevent.
*/
reassignFromRover(roverId);
return;
}
if (action === 'upsert') {
reassignWaiting();
}
});
@@ -225,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;
@@ -241,35 +263,17 @@ function pickRover(socket, options = {}) {
if (candidates.length === 0) {
return null;
}
const dockedRank = (rover) => {
if (!rover) return 0;
if (rover.docked === true) return -1;
if (rover.docked === false) return 1;
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
const docked = sensors?.chargingSources?.homeBase;
if (docked === true) return -1;
if (docked === false) return 1;
return 0;
};
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
const compare = (a, b) => {
const aEmpty = idleRank(a);
const bEmpty = idleRank(b);
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
const aDockRank = dockedRank(a);
const bDockRank = dockedRank(b);
if (aEmpty === 1 && aDockRank !== bDockRank) {
return bDockRank - aDockRank;
}
if (a.drivers.size !== b.drivers.size) {
return a.drivers.size - b.drivers.size;
}
return bDockRank - aDockRank;
};
candidates.sort(compare);
/*
Eligibility is resolved above, while this shared comparator owns only the
requested placement order: empty, undocked when empty, driver count, then
battery percentage.
Keeping those concerns separate prevents a ranking change from weakening
lock, private-rover, role, or mode access checks.
*/
candidates.sort(compareRoversForAssignment);
const best = candidates[0];
if (!best) return null;
const bestTier = candidates.filter((entry) => compare(entry, best) === 0);
const bestTier = candidates.filter((entry) => compareRoversForAssignment(entry, best) === 0);
if (!bestTier.length) return best;
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
}
@@ -0,0 +1,87 @@
// Rover assignment ranking
// Purpose: Ranks otherwise eligible rovers using the fleet's assignment priorities.
// Scope: Contains only deterministic comparison logic; access checks and the final random tie-break remain in assignmentService.
function readDockedState(rover) {
/*
The rover record normally exposes the server's canonical docked state. The
sensor fallback covers the short interval where telemetry has arrived but
the derived top-level field has not yet been synchronized. Unknown docking
state deliberately remains unknown instead of being treated as undocked.
*/
if (rover?.docked === true || rover?.docked === false) return rover.docked;
const sensors = rover?.lastSensor?.decoded || rover?.lastSensor?.sensors || null;
const homeBase = sensors?.chargingSources?.homeBase;
return homeBase === true || homeBase === false ? homeBase : null;
}
function driverCount(rover) {
/*
Production rover records use a Set. Returning a safe high-level count here
keeps ranking predictable for partially initialized records and makes the
comparator straightforward to exercise with small test fixtures.
*/
return Number.isFinite(rover?.drivers?.size) ? rover.drivers.size : 0;
}
function batteryPercentage(rover) {
/*
percentDisplay is the canonical server-normalized percentage used by the
rest of the application. Missing or invalid telemetry receives no invented
percentage; the comparator places unknown batteries after every known one.
*/
const percentage = rover?.batteryState?.percentDisplay;
return Number.isFinite(percentage) ? percentage : null;
}
function compareRoversForAssignment(left, right) {
/*
Spread drivers across the fleet before adding another person to an existing
rover queue. This comparison is deliberately independent of battery: a
small battery-percentage difference should never concentrate users on one
rover while another eligible rover has nobody assigned.
*/
const leftDrivers = driverCount(left);
const rightDrivers = driverCount(right);
const leftEmpty = leftDrivers === 0;
const rightEmpty = rightDrivers === 0;
if (leftEmpty !== rightEmpty) return leftEmpty ? -1 : 1;
/*
When both choices are empty, prefer the rover that is already away from its
dock. Docking state does not separate occupied rovers because queue balance
is more useful there, and an existing driver may already be handling the
rover's physical state. Unknown docking telemetry receives no undocked
preference rather than being guessed as ready.
*/
if (leftEmpty && rightEmpty) {
const leftUndocked = readDockedState(left) === false;
const rightUndocked = readDockedState(right) === false;
if (leftUndocked !== rightUndocked) return leftUndocked ? -1 : 1;
}
/*
For occupied rovers, queue length is the primary balancing signal. This is
intentionally evaluated before battery so a one-percent battery advantage
cannot cause every later user to pile onto the same rover.
*/
if (leftDrivers !== rightDrivers) return leftDrivers - rightDrivers;
const leftBattery = batteryPercentage(left);
const rightBattery = batteryPercentage(right);
const leftHasBattery = leftBattery != null;
const rightHasBattery = rightBattery != null;
if (leftHasBattery !== rightHasBattery) return leftHasBattery ? -1 : 1;
if (leftHasBattery && leftBattery !== rightBattery) return rightBattery - leftBattery;
/*
Returning zero is intentional. assignmentService randomly selects from the
complete best tier so stable Map insertion order cannot permanently favor a
rover whose emptiness, docking state, load, and battery are all equivalent.
*/
return 0;
}
module.exports = {
compareRoversForAssignment,
};
@@ -0,0 +1,77 @@
// Rover assignment ranking tests
// Purpose: Locks the operator-defined rover priority order against accidental comparator regressions.
// Scope: Tests pure ranking only; assignment side effects and access policy remain owned by their existing services.
const test = require('node:test');
const assert = require('node:assert/strict');
const { compareRoversForAssignment } = require('./roverRanking');
function rover({ id, docked, battery, drivers = 0 }) {
/*
Set size matches the production rover contract without introducing socket or
rover-manager dependencies into these focused ordering tests.
*/
return {
id,
docked,
batteryState: battery == null ? null : { percentDisplay: battery },
drivers: new Set(Array.from({ length: drivers }, (_, index) => `${id}-driver-${index}`)),
};
}
function rankedIds(entries) {
return entries.sort(compareRoversForAssignment).map((entry) => entry.id);
}
test('an empty rover outranks an occupied rover regardless of battery or docking state', () => {
const result = rankedIds([
rover({ id: 'occupied-high', docked: false, battery: 100, drivers: 1 }),
rover({ id: 'docked-empty', docked: true, battery: 20 }),
]);
assert.deepEqual(result, ['docked-empty', 'occupied-high']);
});
test('an undocked rover is preferred when both rovers are empty', () => {
const result = rankedIds([
rover({ id: 'docked-high', docked: true, battery: 100 }),
rover({ id: 'undocked-low', docked: false, battery: 20 }),
]);
assert.deepEqual(result, ['undocked-low', 'docked-high']);
});
test('lowest driver count ranks occupied rovers before battery percentage', () => {
const result = rankedIds([
rover({ id: 'busy-high', docked: false, battery: 100, drivers: 4 }),
rover({ id: 'quieter-low', docked: false, battery: 20, drivers: 1 }),
]);
assert.deepEqual(result, ['quieter-low', 'busy-high']);
});
test('battery percentage ranks rovers after availability and load are equal', () => {
const result = rankedIds([
rover({ id: 'low', docked: false, battery: 35, drivers: 1 }),
rover({ id: 'high', docked: false, battery: 90, drivers: 1 }),
rover({ id: 'middle', docked: false, battery: 60, drivers: 1 }),
]);
assert.deepEqual(result, ['high', 'middle', 'low']);
});
test('known battery percentage outranks missing battery telemetry', () => {
const result = rankedIds([
rover({ id: 'unknown', docked: true, battery: null }),
rover({ id: 'known', docked: true, battery: 5 }),
]);
assert.deepEqual(result, ['known', 'unknown']);
});
test('exactly equivalent rovers remain tied for random selection by assignmentService', () => {
const left = rover({ id: 'left', docked: false, battery: 80, drivers: 1 });
const right = rover({ id: 'right', docked: false, battery: 80, drivers: 1 });
assert.equal(compareRoversForAssignment(left, right), 0);
assert.equal(compareRoversForAssignment(right, left), 0);
});
@@ -1,55 +0,0 @@
// audio Forward Service bonk sound
// Purpose: Plays the built-in bonk sound effect on the rover a bonked user is driving.
// Scope: Keeps the fun commands and the audio pipeline decoupled by listening to the server event bus only.
const path = require('path');
const fs = require('fs');
const { subscribe } = require('../eventBus');
/*
Lives in server/assets rather than server/public because the webui build writes
to server/public with emptyOutDir enabled, which deletes anything else in there.
server/assets is a plain checked-in asset directory that no build step touches.
*/
const BONK_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'assets', 'bonk.wav');
function registerBonkSound(deps) {
const {
logger,
playServerAudioFile,
soundPath = BONK_SOUND_PATH,
} = deps;
subscribe('fun.bonked', (event = {}) => {
const roverId = String(event?.payload?.roverId || '').trim();
if (!roverId) return;
/*
The sound is optional. An operator who has not dropped a bonk.wav into
server/assets still gets a fully working `rs bonk` command, so a missing
file is reported once at debug volume rather than thrown at the caller.
*/
if (!fs.existsSync(soundPath)) {
logger.info('Bonk sound file is not installed; skipping playback', { soundPath });
return;
}
try {
playServerAudioFile(roverId, soundPath, { source: 'bonk' });
logger.info('Played bonk sound', { roverId, soundPath });
} catch (err) {
// Playback interrupts mic forwarding and spawns ffmpeg, so an offline rover
// or a missing encoder must not turn into a failed chat command. The bonk
// itself already happened; the sound is layered on top of it.
logger.warn('Failed to play bonk sound', {
roverId,
soundPath,
error: err?.message || String(err),
});
}
});
}
module.exports = {
registerBonkSound,
BONK_SOUND_PATH,
};
@@ -1,84 +0,0 @@
// audio Forward Service bonk sound tests
// Purpose: Verifies the bonk cue plays for a real event and stays contained when the file or rover is missing.
// Scope: Subscribes through the real event bus with a playback double; no ffmpeg runs.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { publishEvent } = require('../eventBus');
const { registerBonkSound, BONK_SOUND_PATH } = require('./bonkSound');
const soundDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bonk-sound-test-'));
const presentSound = path.join(soundDir, 'bonk.wav');
fs.writeFileSync(presentSound, 'not really audio, only the path is read here');
const missingSound = path.join(soundDir, 'absent.wav');
function harness({ soundPath = presentSound, playImpl = null } = {}) {
const played = [];
const warnings = [];
registerBonkSound({
logger: {
info: () => {},
warn: (message, meta) => warnings.push({ message, meta }),
},
playServerAudioFile: (roverId, filePath, options) => {
played.push({ roverId, filePath, options });
if (playImpl) playImpl();
},
soundPath,
});
return { played, warnings };
}
// Each registerBonkSound call adds another subscriber to the shared bus, so every
// test publishes a distinct rover id and asserts only on its own rover.
function bonk(roverId) {
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId, targetLabel: 'bob' } });
}
test('a bonk event plays the sound on the named rover', () => {
const { played } = harness();
bonk('rover-play');
const mine = played.filter((entry) => entry.roverId === 'rover-play');
assert.equal(mine.length, 1);
assert.equal(mine[0].filePath, presentSound);
assert.equal(mine[0].options.source, 'bonk');
});
test('an event with no rover id is ignored', () => {
const { played } = harness();
publishEvent({ source: 'test', type: 'fun.bonked', payload: {} });
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId: ' ' } });
assert.equal(played.length, 0);
});
test('a missing sound file skips playback instead of throwing', () => {
const { played, warnings } = harness({ soundPath: missingSound });
assert.doesNotThrow(() => bonk('rover-missing'));
assert.equal(played.filter((entry) => entry.roverId === 'rover-missing').length, 0);
assert.equal(warnings.length, 0, 'a not-installed sound is informational, not a warning');
});
test('a playback failure is contained and logged rather than thrown at the caller', () => {
const { warnings } = harness({
playImpl: () => {
throw new Error('Rover offline');
},
});
assert.doesNotThrow(() => bonk('rover-offline'));
assert.ok(warnings.some((entry) => entry.meta?.error === 'Rover offline'));
});
test('the default sound path lives in server/assets, which the webui build does not wipe', () => {
// webui/vite.config.js builds to ../server/public with emptyOutDir enabled, so a
// sound stored there would be deleted by the next build.
assert.match(BONK_SOUND_PATH, /server\/assets\/bonk\.wav$/);
assert.doesNotMatch(BONK_SOUND_PATH, /server\/public/);
});
test.after(() => {
fs.rmSync(soundDir, { recursive: true, force: true });
});
@@ -6,6 +6,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
@@ -14,7 +15,6 @@ const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine');
const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const { registerBonkSound } = require('./bonkSound');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
@@ -26,7 +26,13 @@ const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
/*
FIFOs and uploaded clips are disposable, but they are deliberately created
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
Node process from writing to an unrelated host temp directory and prevents a
configuration value from escaping the server's filesystem boundary.
*/
const runtimeDir = resolveRuntimePath('audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
@@ -152,11 +158,6 @@ registerChargeCompleteSound({
playServerAudioFile,
});
registerBonkSound({
logger,
playServerAudioFile,
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
@@ -1,79 +1,62 @@
// audio Levels Gain Math
// Purpose: Holds the pure clamping and ceiling rules shared by every gain layer.
// Scope: No IO, no state; keeps the volume policy independently reviewable and testable.
/*
The three gain keys are the same on every layer of this feature: the global
admin gains, the admin-editable VIP boost caps, and each user's personal
preference. Iterating one list keeps those layers from drifting apart.
*/
const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain'];
// Absolute gain limits accepted anywhere a multiplier is stored.
// Audio Adjustment Math
// Purpose: Converts signed browser percentages into server-enforced rover gain multipliers.
// Scope: Contains no IO or identity logic so the adjustment policy can be tested independently.
const ADJUSTMENT_FIELDS = [
{ gainKey: 'hornGain', percentKey: 'hornPercent' },
{ gainKey: 'ttsGain', percentKey: 'ttsPercent' },
{ gainKey: 'forwardGain', percentKey: 'forwardPercent' },
];
const MIN_GAIN = 0;
const MAX_GAIN = 4;
const MIN_ADJUSTMENT_PERCENT = -100;
const MAX_ADJUSTMENT_PERCENT = 100;
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, num));
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
}
function clampFraction(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(1, num));
function clampMaximumAdjustmentPercent(value, fallback = 50) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.round(Math.max(0, Math.min(MAX_ADJUSTMENT_PERCENT, number)));
}
function normalizeUserGains(raw = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampFraction(raw?.[key], 1);
function clampAdjustmentPercent(value, maximum = 0) {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
const limit = clampMaximumAdjustmentPercent(maximum, 0);
return Math.round(Math.max(-limit, Math.min(limit, number)));
}
function normalizeAdjustments(raw = {}, maximum = 0) {
const normalized = {};
ADJUSTMENT_FIELDS.forEach(({ percentKey }) => {
normalized[percentKey] = clampAdjustmentPercent(raw?.[percentKey], maximum);
});
return out;
return normalized;
}
function normalizeGainSet(raw = {}, fallback = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
function applyAdjustments(baseLevels = {}, adjustments = {}) {
const effective = {};
ADJUSTMENT_FIELDS.forEach(({ gainKey, percentKey }) => {
const base = clampGain(baseLevels?.[gainKey], 0);
const percentage = Math.max(MIN_ADJUSTMENT_PERCENT, Math.min(MAX_ADJUSTMENT_PERCENT, Number(adjustments?.[percentKey]) || 0));
effective[gainKey] = clampGain(base * (1 + percentage / 100), 0);
});
return out;
}
/*
A user without the boost flag can never exceed the global admin gain. The flag
raises the ceiling to the admin-managed hard cap, and Math.max keeps the flag
from ever being a downgrade: if an admin runs the global gain higher than the
boost cap, a boosted user keeps the global ceiling instead of losing volume
for holding a permission.
*/
function resolveCeilings({ adminLimits = {}, boostCaps = {}, hasBoost = false } = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
const adminCeiling = clampGain(adminLimits?.[key], 0);
out[key] = hasBoost ? Math.max(adminCeiling, clampGain(boostCaps?.[key], 0)) : adminCeiling;
});
return out;
}
// Personal preferences are fractions of whichever ceiling applies to the user.
function applyCeilings(fractions = {}, ceilings = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
});
return out;
return effective;
}
module.exports = {
GAIN_KEYS,
ADJUSTMENT_FIELDS,
MIN_GAIN,
MAX_GAIN,
MIN_ADJUSTMENT_PERCENT,
MAX_ADJUSTMENT_PERCENT,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
clampMaximumAdjustmentPercent,
clampAdjustmentPercent,
normalizeAdjustments,
applyAdjustments,
};
@@ -1,85 +1,37 @@
// audio Levels Gain Math Tests
// Purpose: Pins the ceiling rules that keep user volume inside admin limits.
// Scope: Pure math only; no store, socket, or rover involvement.
// Audio Adjustment Math Tests
// Purpose: Pins percentage clamping and conversion independently of sockets, identity, and rover IO.
// Scope: Covers only the pure rules used by audioLevelsService.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
clampFraction,
clampGain,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
} = require('./gainMath');
const { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = require('./gainMath');
const ADMIN_LIMITS = { hornGain: 0.3, ttsGain: 0.2, forwardGain: 0.1 };
const BOOST_CAPS = { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 };
test('an unboosted user is capped by the global admin gains', () => {
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: false });
assert.deepEqual(ceilings, ADMIN_LIMITS);
test('the configured range is a whole percentage from zero through one hundred', () => {
assert.equal(clampMaximumAdjustmentPercent(-5), 0);
assert.equal(clampMaximumAdjustmentPercent(32.6), 33);
assert.equal(clampMaximumAdjustmentPercent(500), 100);
});
test('the boost flag raises the ceiling to the hard caps', () => {
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: true });
assert.deepEqual(ceilings, BOOST_CAPS);
});
test('the boost flag never lowers a ceiling when admin gains exceed the caps', () => {
const loud = { hornGain: 2, ttsGain: 1.5, forwardGain: 3 };
const ceilings = resolveCeilings({ adminLimits: loud, boostCaps: BOOST_CAPS, hasBoost: true });
assert.deepEqual(ceilings, loud);
});
test('a full personal slider resolves to exactly the ceiling', () => {
const effective = applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ADMIN_LIMITS);
assert.deepEqual(effective, ADMIN_LIMITS);
});
test('a personal slider scales the ceiling rather than replacing it', () => {
const effective = applyCeilings({ hornGain: 0.5, ttsGain: 0.5, forwardGain: 0.5 }, BOOST_CAPS);
assert.deepEqual(effective, { hornGain: 0.25, ttsGain: 0.4, forwardGain: 0.2 });
});
test('an out-of-range personal value cannot escape the ceiling', () => {
const effective = applyCeilings({ hornGain: 12, ttsGain: -4, forwardGain: 'loud' }, ADMIN_LIMITS);
assert.equal(effective.hornGain, ADMIN_LIMITS.hornGain);
assert.equal(effective.ttsGain, 0);
// A non-numeric value falls back to the full slider, still bounded by the ceiling.
assert.equal(effective.forwardGain, ADMIN_LIMITS.forwardGain);
});
test('a zero admin gain silences even a boosted user at full slider', () => {
const ceilings = resolveCeilings({
adminLimits: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
boostCaps: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
hasBoost: true,
});
assert.deepEqual(applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ceilings), {
hornGain: 0,
ttsGain: 0,
forwardGain: 0,
test('each browser percentage is clamped equally in both directions', () => {
assert.deepEqual(normalizeAdjustments({ hornPercent: -80, ttsPercent: 10, forwardPercent: 90 }, 40), {
hornPercent: -40,
ttsPercent: 10,
forwardPercent: 40,
});
});
test('personal values normalize into the 0..1 range with a full-volume default', () => {
assert.deepEqual(normalizeUserGains({ hornGain: 0.25, ttsGain: 9 }), {
hornGain: 0.25,
ttsGain: 1,
forwardGain: 1,
});
test('signed percentages adjust each server base gain', () => {
assert.deepEqual(
applyAdjustments(
{ hornGain: 1, ttsGain: 2, forwardGain: 0.5 },
{ hornPercent: -25, ttsPercent: 25, forwardPercent: 40 },
),
{ hornGain: 0.75, ttsGain: 2.5, forwardGain: 0.7 },
);
});
test('gain sets normalize into the 0..4 range and fall back per key', () => {
assert.deepEqual(normalizeGainSet({ hornGain: 9, ttsGain: 'x' }, BOOST_CAPS), {
hornGain: 4,
ttsGain: BOOST_CAPS.ttsGain,
forwardGain: BOOST_CAPS.forwardGain,
});
});
test('clamps reject non-finite input by returning the supplied fallback', () => {
assert.equal(clampGain(Number.NaN, 0.7), 0.7);
assert.equal(clampGain(Infinity, 0.7), 0.7);
assert.equal(clampFraction(undefined, 0.4), 0.4);
test('effective gains remain inside the rover hard bounds', () => {
assert.deepEqual(
applyAdjustments({ hornGain: 4, ttsGain: 0, forwardGain: 3 }, { hornPercent: 100, ttsPercent: -100, forwardPercent: 100 }),
{ hornGain: 4, ttsGain: 0, forwardGain: 4 },
);
});
+90 -109
View File
@@ -7,18 +7,16 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin } = require('../roleService');
const { isAdmin, roleEvents } = require('../roleService');
const roverManager = require('../roverManager');
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
const { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
const { issueCommand } = require('../commandService');
const {
GAIN_KEYS,
ADJUSTMENT_FIELDS,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
clampMaximumAdjustmentPercent,
normalizeAdjustments,
applyAdjustments,
} = require('./gainMath');
const audioLevelsEvents = new EventEmitter();
@@ -26,48 +24,32 @@ const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('audio-levels.json');
const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const configuredUserCaps = configuredDefaults.userGainCaps || {};
/*
Per-user preferences live in identity feature state so they follow the user
across browsers and cannot be raised by editing a client-side cookie. They are
stored as a 0..1 fraction of whatever ceiling currently applies rather than an
absolute gain, so lowering the global admin gain immediately quiets everyone
without having to rewrite every stored preference.
*/
const USER_GAINS_NAMESPACE = 'audioGains';
/*
Absolute ceilings for users holding the audioGainBoost flag. These are the
hard caps the flag cannot exceed; admins can retune them from the driver page.
*/
const USER_GAIN_CAP_DEFAULTS = {
hornGain: 0.5,
ttsGain: 0.8,
forwardGain: 0.4,
};
const PERSONAL_ADJUSTMENT_PERMISSION = 'audio.personalAdjustment';
const DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT = 50;
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
configuredDefaults.maxPersonalAdjustmentPercent,
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
),
};
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
return normalizeGainSet(raw, fallback);
}
function normalizeStore(raw = {}) {
return {
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
raw.maxPersonalAdjustmentPercent,
DEFAULTS.maxPersonalAdjustmentPercent,
),
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
capsUpdatedAt: Number.isFinite(raw.capsUpdatedAt) ? raw.capsUpdatedAt : null,
capsUpdatedBy: typeof raw.capsUpdatedBy === 'string' ? raw.capsUpdatedBy : null,
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
};
}
@@ -78,6 +60,11 @@ function loadState() {
try {
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
state = normalizeStore(raw);
if (Object.prototype.hasOwnProperty.call(raw, 'userGainCaps')) {
// Rewrite once so the retired VIP-cap object does not linger beside the
// new percentage range and confuse future operator inspection.
persistState(state);
}
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load audio levels store', err.message);
@@ -103,18 +90,14 @@ function getAudioLevels() {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
userGainCaps: { ...current.userGainCaps },
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
updatedAt: current.updatedAt,
updatedBy: current.updatedBy,
capsUpdatedAt: current.capsUpdatedAt,
capsUpdatedBy: current.capsUpdatedBy,
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
};
}
function getUserGainCaps() {
return { ...loadState().userGainCaps };
}
function emitChange(reason = 'update', extra = {}) {
audioLevelsEvents.emit('change', {
reason,
@@ -132,30 +115,19 @@ function getAdminLimits() {
};
}
function getGainCeilings(hasBoost) {
const current = loadState();
return resolveCeilings({
adminLimits: getAdminLimits(),
boostCaps: current.userGainCaps,
hasBoost,
});
function canUsePersonalAdjustments(socket) {
if (isAdmin(socket)) return true;
const userId = getUserIdForSocket(socket);
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
}
function getGainCeilingsForSocket(socket) {
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
}
function getUserGains(userId) {
if (!userId) return normalizeUserGains({});
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
}
function getUserGainsForSocket(socket) {
return getUserGains(getUserIdForSocket(socket));
function getAdjustmentsForSocket(socket) {
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
}
function getEffectiveLevelsForSocket(socket) {
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
return applyAdjustments(getAdminLimits(), getAdjustmentsForSocket(socket));
}
/*
@@ -234,55 +206,49 @@ function setAudioLevels(input = {}, actor = null) {
return getAudioLevels();
}
function setUserGainCaps(input = {}, actor = null) {
function setMaxPersonalAdjustmentPercent(value, actor = null) {
const current = loadState();
const next = {
...current,
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
capsUpdatedAt: Date.now(),
capsUpdatedBy: actor,
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
adjustmentRangeUpdatedAt: Date.now(),
adjustmentRangeUpdatedBy: actor,
};
persistState(next);
/*
Lowering a cap has to take effect immediately for anyone already driving,
otherwise a boosted user keeps the louder gain until their next turn.
A narrower range must take effect immediately for current drivers rather
than leaving an out-of-range multiplier active until their next turn.
*/
pushLevelsToAllRovers();
emitChange('user_caps_set');
return getUserGainCaps();
emitChange('personal_adjustment_range_set');
return loadState().maxPersonalAdjustmentPercent;
}
function setUserGains(socket, input = {}) {
const userId = getUserIdForSocket(socket);
if (!userId) throw new Error('Identity required');
const current = getUserGains(userId);
const next = { ...current };
GAIN_KEYS.forEach((key) => {
if (input?.[key] === undefined) return;
next[key] = clampFraction(input[key], current[key]);
});
setFeatureState(userId, USER_GAINS_NAMESPACE, next);
function setSocketAdjustments(socket, input = {}) {
socket.data = socket.data || {};
// Store only server-normalized percentages on the transport. The cookie is a
// browser preference, while permission and range enforcement remain here.
socket.data.audioAdjustments = normalizeAdjustments(input, 100);
pushLevelsForSocket(socket);
emitChange('user_gains_set', { scope: 'user', userId });
return getAudioGainStateForSocket(socket);
emitChange('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
return getAudioAdjustmentStateForSocket(socket);
}
/*
The client needs all three layers to render an honest slider: its own stored
fraction, the ceiling that fraction is measured against, and the resolved gain
so the UI can show what the rover will actually play.
The client receives the percentages the server accepted, the permitted range,
and the resulting multipliers. This keeps the UI honest even when a cookie was
edited or an administrator changed permission while the browser was online.
*/
function getAudioGainStateForSocket(socket) {
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
const values = getUserGainsForSocket(socket);
const ceilings = getGainCeilings(hasBoost);
function getAudioAdjustmentStateForSocket(socket) {
const allowed = canUsePersonalAdjustments(socket);
const maximum = loadState().maxPersonalAdjustmentPercent;
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
return {
values,
ceilings,
effective: applyCeilings(values, ceilings),
boostGranted: hasBoost,
adminLimits: getAdminLimits(),
boostCaps: getUserGainCaps(),
allowed,
maxAdjustmentPercent: maximum,
effective: applyAdjustments(getAdminLimits(), values),
baseLevels: getAdminLimits(),
};
}
@@ -311,6 +277,23 @@ setImmediate(() => {
}
});
identityEvents.on('change', ({ reason, userId } = {}) => {
if (!userId || !['permission_granted', 'permission_revoked', 'identify'].includes(reason)) return;
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) !== userId) return;
pushLevelsForSocket(socket);
// Permission changes alter both effective rover output and the controls the
// browser may use, so each affected connection receives a fresh session.
emitChange('personal_adjustment_permission_changed', { scope: 'socket', socketId: socket.id });
});
});
roleEvents.on('change', ({ socket } = {}) => {
// Administrators implicitly have this capability, so login/logout can change
// the effective adjustment even though no database permission row changed.
if (socket) pushLevelsForSocket(socket);
});
io.on('connection', (socket) => {
socket.on('audioLevels:get', (_, cb = () => {}) => {
cb({ success: true, levels: getAudioLevels() });
@@ -329,30 +312,30 @@ io.on('connection', (socket) => {
}
});
socket.on('audioLevels:setUserCaps', (payload = {}, cb = () => {}) => {
socket.on('audioLevels:setPersonalAdjustmentRange', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const actor = socket?.data?.user?.username || null;
const userGainCaps = setUserGainCaps(payload || {}, actor);
cb({ success: true, userGainCaps });
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
cb({ success: true, maxPersonalAdjustmentPercent });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
try {
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
try {
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
} catch (err) {
cb({ error: err.message });
}
@@ -362,17 +345,15 @@ io.on('connection', (socket) => {
loadState();
module.exports = {
GAIN_KEYS,
USER_GAIN_CAP_DEFAULTS,
ADJUSTMENT_FIELDS,
PERSONAL_ADJUSTMENT_PERMISSION,
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
getAudioLevels,
setAudioLevels,
getUserGainCaps,
setUserGainCaps,
getUserGains,
setUserGains,
getGainCeilingsForSocket,
setMaxPersonalAdjustmentPercent,
setSocketAdjustments,
getEffectiveLevelsForSocket,
getAudioGainStateForSocket,
getAudioAdjustmentStateForSocket,
pushLevelsToRover,
audioLevelsEvents,
};
@@ -401,6 +401,15 @@ function handleWorkerMessage(message = {}) {
updateStatus('starting', 'Starting Bluetooth discovery.');
} else if (workerState === 'discovering') {
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
} else if (workerState === 'device-detected') {
// Preserve the worker's exact identification stage instead of leaving the
// panel apparently unchanged when an adapter sees only the board's address.
// This is intentionally not a feed alert because ambient unresolved devices
// can appear during commissioning and the state is already visible locally.
updateStatus(
'identifying',
message.error || 'Bluetooth device detected; checking whether it is the Balance Board.',
);
} else if (workerState === 'pairing') {
updateStatus('pairing', 'Board found. Pairing now.');
} else if (workerState === 'connected') {
@@ -73,17 +73,25 @@ constexpr uint16_t kMgmtCommandCompleteEvent = 0x0001;
constexpr uint16_t kMgmtCommandStatusEvent = 0x0002;
constexpr uint16_t kMgmtNewSettingsEvent = 0x0006;
constexpr uint16_t kMgmtPinCodeRequestEvent = 0x000e;
constexpr uint16_t kMgmtDeviceFoundEvent = 0x0012;
constexpr uint16_t kMgmtDiscoveringEvent = 0x0013;
constexpr uint16_t kMgmtPinCodeReplyCommand = 0x0016;
constexpr uint16_t kMgmtSetConnectableCommand = 0x0007;
constexpr uint16_t kMgmtSetFastConnectableCommand = 0x0008;
constexpr uint16_t kMgmtStartDiscoveryCommand = 0x0023;
constexpr uint16_t kMgmtStopDiscoveryCommand = 0x0024;
constexpr uint16_t kPrimaryControllerIndex = 0;
constexpr uint8_t kBluetoothClassicAddressType = 0;
constexpr uint8_t kBluetoothClassicDiscoveryMask = 1U << 0;
constexpr uint32_t kDeviceFoundLegacyPairingFlag = 1U << 1;
constexpr uint8_t kEirClassOfDeviceType = 0x0d;
constexpr uint32_t kBalanceBoardClassOfDevice = 0x00002504;
constexpr uint32_t kControllerConnectableSetting = 1U << 1;
constexpr uint32_t kControllerFastConnectableSetting = 1U << 2;
constexpr int kManagementCommandTimeoutMs = 2000;
constexpr int kFrameIntervalMs = 50;
constexpr int kDiscoveryRestartDelayMs = 1000;
constexpr const char* kDiscoveryTimeoutSeconds = "86400";
constexpr int kDiscoveryStartDeadlineMs = 5000;
constexpr uint16_t kHidControlPsm = 0x0011;
constexpr uint16_t kHidInterruptPsm = 0x0013;
constexpr int kCommissioningConnectWindowMs = 15000;
@@ -107,6 +115,16 @@ struct PairingSharedState {
std::optional<BluetoothAddress> active_target;
std::optional<BluetoothAddress> active_pin;
std::optional<std::string> commissioned_address;
// Discovery commands and events use the same kernel management socket as
// raw Wii PIN replies. The main thread owns socket reads while the
// commissioning thread consumes this small synchronized state, avoiding a
// second reader that could steal PIN or controller-setting events.
std::optional<BluetoothAddress> discovery_candidate;
std::string discovery_error;
bool discovery_start_pending = false;
bool discovery_stop_pending = false;
bool discovery_session_started = false;
bool discovery_active = false;
bool commissioning = false;
bool outbound_connection_requested = false;
};
@@ -123,13 +141,6 @@ struct CommandResult {
std::string output;
};
struct RunningCommand {
pid_t pid = -1;
int output_fd = -1;
std::string pending_output;
std::string transcript;
};
struct ManagementRuntimeState {
// Runtime reassertions are asynchronous so a temporary controller setting
// change cannot block PIN or HID handling. Track each outstanding opcode to
@@ -215,6 +226,24 @@ std::optional<BluetoothAddress> parse_address(const std::string& raw) {
return address;
}
BluetoothAddress address_from_management_wire(const uint8_t* wire) {
BluetoothAddress address;
if (!wire) return address;
// Management packets carry Bluetooth addresses least-significant byte first,
// while every BlueZ command and user-facing status expects the conventional
// most-significant-byte-first representation. Preserve both forms because
// the original wire bytes are later compared with the kernel PIN request.
std::copy(wire, wire + address.wire.size(), address.wire.begin());
char address_buffer[18]{};
std::snprintf(
address_buffer, sizeof(address_buffer), "%02X:%02X:%02X:%02X:%02X:%02X",
address.wire[5], address.wire[4], address.wire[3],
address.wire[2], address.wire[1], address.wire[0]);
address.display = address_buffer;
return address;
}
CommandResult run_command(const std::vector<std::string>& args) {
CommandResult result;
if (args.empty()) return result;
@@ -260,127 +289,22 @@ CommandResult run_command(const std::vector<std::string>& args) {
return result;
}
RunningCommand start_command(const std::vector<std::string>& args) {
RunningCommand command;
if (args.empty()) return command;
bool candidate_is_balance_board(const BluetoothAddress& address) {
const CommandResult info = run_command({
"bluetoothctl", "--timeout", "2", "info", address.display});
if (info.output.find(kBoardBluetoothName) != std::string::npos) return true;
int pipe_fds[2]{};
if (pipe(pipe_fds) != 0) {
command.transcript = std::strerror(errno);
return command;
}
const pid_t pid = fork();
if (pid == 0) {
dup2(pipe_fds[1], STDOUT_FILENO);
dup2(pipe_fds[1], STDERR_FILENO);
close(pipe_fds[0]);
close(pipe_fds[1]);
std::vector<char*> argv;
argv.reserve(args.size() + 1);
for (const auto& arg : args) argv.push_back(const_cast<char*>(arg.c_str()));
argv.push_back(nullptr);
execvp(argv[0], argv.data());
_exit(127);
}
close(pipe_fds[1]);
if (pid < 0) {
command.transcript = std::strerror(errno);
close(pipe_fds[0]);
return command;
}
// Discovery has no predetermined completion time: it must remain active until
// the user wakes the board. A nonblocking pipe lets the commissioning thread
// consume BlueZ events while still honoring server shutdown and maintenance
// commands promptly.
const int current_flags = fcntl(pipe_fds[0], F_GETFL, 0);
if (current_flags >= 0) fcntl(pipe_fds[0], F_SETFL, current_flags | O_NONBLOCK);
command.pid = pid;
command.output_fd = pipe_fds[0];
return command;
}
bool collect_command_output(RunningCommand* command) {
if (!command || command->pid < 0) return false;
std::array<char, 1024> buffer{};
ssize_t count = 0;
while ((count = read(command->output_fd, buffer.data(), buffer.size())) > 0) {
const std::string chunk(buffer.data(), static_cast<std::size_t>(count));
command->pending_output += chunk;
command->transcript += chunk;
// A busy Bluetooth environment can produce an unbounded stream of RSSI
// updates. Retain only the most recent output instead of allowing a
// commissioning session left open for days to grow the worker indefinitely.
constexpr std::size_t max_transcript_size = 8192;
if (command->transcript.size() > max_transcript_size) {
command->transcript.erase(0, command->transcript.size() - max_transcript_size);
}
}
int status = 0;
const pid_t waited = waitpid(command->pid, &status, WNOHANG);
if (waited == 0) return true;
if (waited == command->pid) {
command->pid = -1;
}
return false;
}
void stop_command(RunningCommand* command) {
if (!command) return;
if (command->pid > 0) {
// bluetoothctl normally exits immediately on SIGTERM. Bound that grace
// period so a wedged D-Bus client cannot prevent the server from stopping.
kill(command->pid, SIGTERM);
for (int attempt = 0; attempt < 50 && command->pid > 0; ++attempt) {
collect_command_output(command);
if (command->pid > 0) usleep(10000);
}
if (command->pid > 0) {
kill(command->pid, SIGKILL);
int status = 0;
while (waitpid(command->pid, &status, 0) < 0 && errno == EINTR) {}
command->pid = -1;
}
}
if (command->output_fd >= 0) {
close(command->output_fd);
command->output_fd = -1;
}
}
std::optional<BluetoothAddress> take_discovered_board(RunningCommand* discovery,
bool* discovery_started) {
if (!discovery) return std::nullopt;
std::size_t newline = discovery->pending_output.find('\n');
while (newline != std::string::npos) {
const std::string line = discovery->pending_output.substr(0, newline);
discovery->pending_output.erase(0, newline + 1);
// bluetoothctl reports filter setup before StartDiscovery completes. Treat
// only this explicit event as proof that button presses can now be seen;
// `SetDiscoveryFilter success` alone is not an active Bluetooth scan.
if (discovery_started && line.find("Discovery started") != std::string::npos) {
*discovery_started = true;
}
// A Classic device is initially announced by address and receives its name
// in a later change event. Parse every complete scan line so either BlueZ
// form works, but require the exact Nintendo board name before accepting an
// address. A nearby Wiimote must never become eligible for the raw PIN.
if (line.find(kBoardBluetoothName) != std::string::npos) {
const std::size_t device_prefix = line.find("Device ");
if (device_prefix != std::string::npos && line.size() >= device_prefix + 24) {
if (auto address = parse_address(line.substr(device_prefix + 7, 17))) return address;
}
}
newline = discovery->pending_output.find('\n');
}
return std::nullopt;
// Original Wii input devices identify as legacy-pairing gaming peripherals.
// This fallback is deliberately applied only to an address delivered by the
// kernel's legacy-pairing Device Found event during active commissioning.
// That physical red-Sync action is the selection boundary when an adapter
// cannot resolve Nintendo's remote name in time.
const bool gaming_peripheral =
info.output.find("Class: 0x00002504") != std::string::npos &&
info.output.find("Icon: input-gaming") != std::string::npos;
const bool legacy_pairing =
info.output.find("LegacyPairing: yes") != std::string::npos;
return gaming_peripheral && legacy_pairing;
}
std::string command_error_summary(const std::string& raw, const std::string& fallback) {
@@ -438,7 +362,11 @@ std::optional<BluetoothAddress> find_default_controller() {
return std::nullopt;
}
void commissioning_loop(PairingSharedState* shared) {
bool start_management_discovery(int fd, PairingSharedState* shared,
std::string* error);
void stop_management_discovery(int fd, PairingSharedState* shared);
void commissioning_loop(PairingSharedState* shared, int management_fd) {
while (running.load()) {
bool should_commission = false;
{
@@ -452,57 +380,62 @@ void commissioning_loop(PairingSharedState* shared) {
}
emit_status("commissioning");
// Commissioning must be listening before the board's short red-Sync window
// begins. Keep one BlueZ discovery client alive continuously and consume its
// own event stream. The previous bounded scan exited for twelve seconds at a
// time and then queried a second client, making successful discovery depend
// on when the physical button happened to be pressed.
// BlueZ's command-line client exits after the SetDiscoveryFilter callback
// unless non-interactive mode has a timeout. A one-day timeout keeps the
// client alive for unattended commissioning; the worker normally stops it
// itself as soon as the board appears and restarts it if the day expires.
RunningCommand discovery = start_command({
"bluetoothctl", "--timeout", kDiscoveryTimeoutSeconds, "scan", "bredr"});
if (discovery.pid < 0) {
emit_status("error", "", "could not start Bluetooth discovery: " +
command_error_summary(discovery.transcript, "unknown process error"));
// Discovery is deliberately performed through the kernel management
// socket already required for Wii PIN replies. Long-running bluetoothctl
// output proved version- and terminal-dependent on the production server;
// MGMT Device Found events are the stable interface underneath BlueZ and
// arrive on this socket without parsing human-oriented terminal output.
std::string discovery_error;
if (!start_management_discovery(
management_fd, shared, &discovery_error)) {
emit_status("error", "", "Bluetooth discovery could not start: " +
discovery_error);
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
continue;
}
std::optional<BluetoothAddress> address;
bool discovery_started = false;
while (running.load() && !address.has_value()) {
const bool discovery_running = collect_command_output(&discovery);
const bool was_started = discovery_started;
address = take_discovered_board(&discovery, &discovery_started);
if (!was_started && discovery_started) {
// This status clears any prior scanner error and tells the browser that
// the server is genuinely listening for the board's red Sync button.
emit_status("discovering");
}
if (address.has_value()) break;
if (!discovery_running) {
const std::string detail = command_error_summary(
discovery.transcript, "bluetoothctl exited unexpectedly");
emit_status("error", "", discovery_started
? "Bluetooth scanner stopped unexpectedly; retrying automatically: " + detail
: "Bluetooth scanner exited before discovery started; retrying automatically: " + detail);
break;
}
std::optional<BluetoothAddress> candidate;
bool still_commissioning = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
still_commissioning = shared->commissioning &&
!shared->commissioned_address.has_value();
candidate = shared->discovery_candidate;
shared->discovery_candidate.reset();
discovery_error = shared->discovery_error;
}
if (!still_commissioning) break;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
if (!discovery_error.empty()) {
emit_status("error", "", discovery_error);
break;
}
if (candidate.has_value()) {
emit_status("device-detected", candidate->display,
"Classic Bluetooth device detected; checking whether it is the Balance Board.");
// Class, icon, and legacy-pairing properties can arrive just after the
// first raw inquiry result. Retry that bounded local property lookup at
// quarter-second intervals while the board is awake; this replaces the
// old dependence on a later human-readable bluetoothctl change line.
// The exact identity gate remains mandatory, so an unrelated controller
// can never arm the privileged Wii PIN response.
for (int attempt = 0; attempt < 5 && !address.has_value(); ++attempt) {
if (candidate_is_balance_board(*candidate)) {
address = candidate;
break;
}
if (attempt < 4) {
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
if (!address.has_value()) {
stop_command(&discovery);
stop_management_discovery(management_fd, shared);
if (running.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
}
@@ -511,8 +444,8 @@ void commissioning_loop(PairingSharedState* shared) {
const auto controller = find_default_controller();
if (!controller.has_value()) {
stop_command(&discovery);
emit_status("commissioning", address->display,
stop_management_discovery(management_fd, shared);
emit_status("error", address->display,
"no powered Bluetooth controller is available for pairing");
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
continue;
@@ -533,10 +466,10 @@ void commissioning_loop(PairingSharedState* shared) {
// charge of everything else.
const CommandResult pair_result = run_command({
"bluetoothctl", "--timeout", "12", "--agent", "NoInputNoOutput", "pair", address->display});
// Keep the discovery owner alive through Pair(). BlueZ documents pairing by
// address as requiring an active scan report, and the board may stop its
// Sync window before a new discovery client could be established.
stop_command(&discovery);
// Keep kernel discovery alive through Pair(). BlueZ pairing by address
// requires the fresh device record, and the board's Sync window is too
// short to stop and recreate discovery before bonding begins.
stop_management_discovery(management_fd, shared);
{
std::lock_guard<std::mutex> lock(shared->mutex);
@@ -545,7 +478,7 @@ void commissioning_loop(PairingSharedState* shared) {
}
if (!command_succeeded(pair_result)) {
emit_status("commissioning", address->display,
emit_status("error", address->display,
"pairing failed: " + command_error_summary(
pair_result.output, "BlueZ returned an unknown pairing error"));
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
@@ -608,6 +541,38 @@ uint32_t read_u32_le(const uint8_t* input) {
(static_cast<uint32_t>(input[3]) << 24);
}
bool management_event_has_balance_board_class(const uint8_t* payload,
uint16_t payload_size) {
// Device Found has a fixed 14-byte prefix followed by standard EIR fields.
// Each field begins with a byte count that includes its one-byte type. Parse
// defensively because this data originates over the radio and a malformed
// length must never let commissioning inspect beyond the management packet.
constexpr std::size_t fixed_size = 14;
if (!payload || payload_size < fixed_size) return false;
const uint16_t eir_size = read_u16_le(payload + 12);
if (eir_size > payload_size - fixed_size) return false;
const uint8_t* eir = payload + fixed_size;
std::size_t offset = 0;
while (offset < eir_size) {
const uint8_t field_size = eir[offset];
if (field_size == 0) break;
if (offset + 1 + field_size > eir_size) return false;
const uint8_t field_type = eir[offset + 1];
const std::size_t data_size = field_size - 1;
if (field_type == kEirClassOfDeviceType && data_size >= 3) {
const uint32_t device_class =
static_cast<uint32_t>(eir[offset + 2]) |
(static_cast<uint32_t>(eir[offset + 3]) << 8) |
(static_cast<uint32_t>(eir[offset + 4]) << 16);
return device_class == kBalanceBoardClassOfDevice;
}
offset += 1 + field_size;
}
return false;
}
std::string management_status_description(uint8_t status) {
// These are the management statuses that setting controller modes can
// realistically return. Retain the numeric value as well because it remains
@@ -642,6 +607,112 @@ bool write_management_boolean_command(int fd, uint16_t opcode, bool enabled) {
static_cast<ssize_t>(packet.size());
}
bool write_management_discovery_command(int fd, uint16_t opcode) {
if (fd < 0) return false;
constexpr std::size_t header_size = 6;
std::array<uint8_t, header_size + 1> packet{};
write_u16_le(packet.data(), opcode);
write_u16_le(packet.data() + 2, kPrimaryControllerIndex);
write_u16_le(packet.data() + 4, 1);
// The Balance Board is a Classic Bluetooth device. Restricting discovery to
// BR/EDR avoids irrelevant LE advertisements and ensures every Device Found
// event uses the address type expected by the Wii pairing path.
packet[header_size] = kBluetoothClassicDiscoveryMask;
return write(fd, packet.data(), packet.size()) ==
static_cast<ssize_t>(packet.size());
}
bool start_management_discovery(int fd, PairingSharedState* shared,
std::string* error) {
if (fd < 0 || !shared) {
if (error) *error = "Bluetooth management socket is unavailable";
return false;
}
{
std::lock_guard<std::mutex> lock(shared->mutex);
shared->discovery_candidate.reset();
shared->discovery_error.clear();
shared->discovery_start_pending = true;
shared->discovery_stop_pending = false;
shared->discovery_session_started = false;
shared->discovery_active = false;
}
if (!write_management_discovery_command(fd, kMgmtStartDiscoveryCommand)) {
const std::string detail = "could not send Start Discovery: " +
std::string(std::strerror(errno));
{
std::lock_guard<std::mutex> lock(shared->mutex);
shared->discovery_start_pending = false;
shared->discovery_error = detail;
}
if (error) *error = detail;
return false;
}
// Command Complete proves the kernel accepted the session, while the
// Discovering event proves inquiry is actually active on the controller.
// Require both so the UI can never repeat the earlier false "listening"
// state where a process existed but no radio scan was running.
const uint64_t deadline = monotonic_ms() + kDiscoveryStartDeadlineMs;
while (running.load() && monotonic_ms() < deadline) {
std::string discovery_error;
bool ready = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
discovery_error = shared->discovery_error;
ready = shared->discovery_session_started && shared->discovery_active;
}
if (!discovery_error.empty()) {
if (error) *error = discovery_error;
return false;
}
if (ready) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (error) *error = "kernel accepted no active BR/EDR discovery session within 5 seconds";
stop_management_discovery(fd, shared);
return false;
}
void stop_management_discovery(int fd, PairingSharedState* shared) {
if (fd < 0 || !shared) return;
bool should_stop = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
should_stop = shared->discovery_start_pending ||
shared->discovery_session_started || shared->discovery_active;
shared->discovery_candidate.reset();
if (should_stop) shared->discovery_stop_pending = true;
}
if (!should_stop) return;
if (!write_management_discovery_command(fd, kMgmtStopDiscoveryCommand)) {
std::lock_guard<std::mutex> lock(shared->mutex);
shared->discovery_stop_pending = false;
shared->discovery_error = "could not send Stop Discovery: " +
std::string(std::strerror(errno));
return;
}
// Pairing retries should not collide with a previous inquiry session. Wait
// briefly for the matching command response, but never let a misbehaving
// adapter hold server shutdown or commissioning indefinitely.
const uint64_t deadline = monotonic_ms() + kManagementCommandTimeoutMs;
while (running.load() && monotonic_ms() < deadline) {
bool stopped = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
stopped = !shared->discovery_stop_pending &&
!shared->discovery_session_started;
}
if (stopped) return;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
bool set_management_boolean_and_wait(int fd, uint16_t opcode,
const std::string& setting_name,
std::string* error) {
@@ -758,16 +829,44 @@ void process_management_events(int fd, PairingSharedState* shared,
if (count < 6 + payload_size) continue;
if ((event == kMgmtCommandCompleteEvent || event == kMgmtCommandStatusEvent) &&
payload_size >= 3 && management) {
payload_size >= 3) {
const uint16_t opcode = read_u16_le(buffer.data() + 6);
const uint8_t status = buffer[8];
if (opcode == kMgmtStartDiscoveryCommand ||
opcode == kMgmtStopDiscoveryCommand) {
std::lock_guard<std::mutex> lock(shared->mutex);
if (opcode == kMgmtStartDiscoveryCommand) {
shared->discovery_start_pending = false;
if (status == 0) {
shared->discovery_session_started = true;
} else {
shared->discovery_session_started = false;
shared->discovery_active = false;
shared->discovery_error = "Start Discovery was rejected: " +
management_status_description(status);
}
} else {
shared->discovery_stop_pending = false;
if (status == 0) {
shared->discovery_start_pending = false;
shared->discovery_session_started = false;
shared->discovery_active = false;
} else {
shared->discovery_error = "Stop Discovery was rejected: " +
management_status_description(status);
}
}
continue;
}
bool recognized = false;
std::string setting_name;
if (opcode == kMgmtSetConnectableCommand) {
if (management && opcode == kMgmtSetConnectableCommand) {
management->connectable_pending = false;
recognized = true;
setting_name = "connectable setting";
} else if (opcode == kMgmtSetFastConnectableCommand) {
} else if (management && opcode == kMgmtSetFastConnectableCommand) {
management->fast_connectable_pending = false;
recognized = true;
setting_name = "fast connectable setting";
@@ -779,6 +878,50 @@ void process_management_events(int fd, PairingSharedState* shared,
continue;
}
if (event == kMgmtDiscoveringEvent && payload_size >= 2 &&
adapter_index == kPrimaryControllerIndex) {
const uint8_t address_types = buffer[6];
const bool active = buffer[7] != 0;
bool announce_discovery = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
if (shared->commissioning &&
(address_types & kBluetoothClassicDiscoveryMask) != 0) {
announce_discovery = active && !shared->discovery_active;
shared->discovery_active = active;
}
}
if (announce_discovery) emit_status("discovering");
continue;
}
if (event == kMgmtDeviceFoundEvent && payload_size >= 14 &&
adapter_index == kPrimaryControllerIndex) {
const uint8_t* payload = buffer.data() + 6;
const uint8_t address_type = payload[6];
const uint32_t flags = read_u32_le(payload + 8);
const bool balance_board_class =
management_event_has_balance_board_class(payload, payload_size);
// Some controllers provide the gaming-device class in the first inquiry
// result and add Legacy Pairing only after name resolution; others do the
// reverse. Either radio-level signal is narrow enough to justify the
// bounded BlueZ property check, while ordinary Classic devices never
// disturb the panel or launch repeated identity commands.
if (address_type == kBluetoothClassicAddressType &&
(balance_board_class ||
(flags & kDeviceFoundLegacyPairingFlag) != 0)) {
const BluetoothAddress candidate =
address_from_management_wire(payload);
std::lock_guard<std::mutex> lock(shared->mutex);
if (shared->commissioning &&
!shared->commissioned_address.has_value()) {
shared->discovery_candidate = candidate;
}
}
continue;
}
if (event == kMgmtNewSettingsEvent && payload_size >= 4 &&
adapter_index == kPrimaryControllerIndex && management) {
const uint32_t settings = read_u32_le(buffer.data() + 6);
@@ -1319,7 +1462,8 @@ int main() {
std::thread commission_thread;
std::thread connection_thread;
if (bluetooth_startup_ready) {
commission_thread = std::thread(commissioning_loop, &pairing);
commission_thread = std::thread(
commissioning_loop, &pairing, management_fd);
connection_thread = std::thread(direct_connection_loop, &pairing, boards);
}
std::thread input_thread(stdin_loop, &pairing);
@@ -22,6 +22,9 @@ function createButtonBoxCore(deps) {
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode,
isGreenModeEnabled,
onGreenModeChange,
store,
} = deps;
@@ -215,6 +218,11 @@ function createButtonBoxCore(deps) {
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
setHomeAssistantLightsLockedOn: (next, options = {}) =>
setHomeAssistantLightsLockedOn(next, options),
// Rewards receive the standalone feature boundary rather than reaching
// into Home Assistant or duplicating green-mode state and alerts.
setGreenMode: (next, options = {}) => setGreenMode(next, options),
isGreenModeEnabled: () => isGreenModeEnabled(),
onGreenModeChange: (listener) => onGreenModeChange(listener),
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
};
@@ -24,6 +24,7 @@ const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
const { createButtonBoxStore } = require('./store');
const { createButtonBoxCore } = require('./core');
const { registerButtonBoxRoute } = require('./httpRoute');
const greenModeService = require('../greenModeService');
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json');
@@ -61,6 +62,14 @@ const core = createButtonBoxCore({
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode: greenModeService.setEnabled,
isGreenModeEnabled: greenModeService.isEnabled,
// Return an explicit cleanup function so timed rewards can stop observing
// the global service when they expire, rerun, or are recovered.
onGreenModeChange: (listener) => {
greenModeService.greenModeEvents.on('change', listener);
return () => greenModeService.greenModeEvents.off('change', listener);
},
store,
});
+12 -35
View File
@@ -10,6 +10,7 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const greenModeService = require('../greenModeService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const { isFeatureEnabled } = require('../../helpers/features');
@@ -22,16 +23,17 @@ const {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
} = require('../verificationService');
const {
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
} = require('../identityService');
const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService');
const funStatsService = require('../funStatsService');
const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const { parseCommandText } = require('../operatorCommandService/config');
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
const { commandReplyToText } = require('./commandResultFormatter');
@@ -44,14 +46,6 @@ const {
const config = loadConfig();
const discordConfig = config.discord || {};
/*
Site chat builds a fresh command router for every message so each router can
close over the sending socket. Fun command cooldowns therefore have to live out
here: a gate created inside the router would be thrown away after one message
and would never actually rate limit anything.
*/
const commandCooldowns = createCooldownGate();
function isTextCommand(text) {
return parseCommandText(text, config).matched;
}
@@ -133,12 +127,6 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) {
actor: {
bot: false,
id: socket.id,
/*
Fun command tallies are keyed by identity rather than connection, so the
canonical user id is passed alongside the socket id. Without it a user's
bonk count would reset on every reconnect and split across browser tabs.
*/
userId: String(socket?.data?.userId || '').trim() || null,
label: nickname,
isAdmin: isAdmin(socket),
isLockdownAdmin: isLockdownAdmin(socket),
@@ -187,6 +175,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// lights lock/unlock` from becoming transport-specific, and it preserves
// the existing session update path for all connected browsers.
homeAssistantService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
@@ -203,23 +192,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
funStatsService,
commandCooldowns,
// Lets `rs bonk` announce itself so audioForwardService can play the bonk
// sound on the rover the target is driving.
publishEvent,
/*
Fun commands that move hardware need the sending socket so they can prove
the caller holds control. issueCommand is required lazily for the same
reason replayEngineV2 is: commandService registers socket handlers on load,
and chatService should not pull that forward in the boot order.
*/
getActorSocket: () => socket,
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
+13 -26
View File
@@ -46,23 +46,24 @@ const {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
} = require('../verificationService');
const {
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
} = require('../identityService');
const {
attachDmMessage: attachPrivateAccessDmMessage,
getRequestByMessageId: getPrivateAccessRequestByMessageId,
approveRequest: approvePrivateAccessRequest,
denyRequest: denyPrivateAccessRequest,
} = require('../privateRoverAccessRequestService');
const { subscribe, publishEvent } = require('../eventBus');
const funStatsService = require('../funStatsService');
const { issueCommand } = require('../commandService');
const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const greenModeService = require('../greenModeService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports');
@@ -212,10 +213,6 @@ if (discordConfig?.channels?.replay) {
});
}
// The Discord router is built once for the process, so one gate here covers every
// guild and channel this bot answers in.
const commandCooldowns = createCooldownGate();
const commandDependencies = {
logger,
client,
@@ -244,6 +241,7 @@ const commandDependencies = {
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
@@ -260,22 +258,11 @@ const commandDependencies = {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
funStatsService,
commandCooldowns,
// A Discord bonk still plays the sound on the rover the target is driving; only
// the commands that need the caller's own socket are unavailable from here.
publishEvent,
/*
Discord has no socket behind a message, so the hardware-backed fun commands
cannot prove drive control and decline with an explanation instead. The text,
counter, and read-only fun commands work normally from here.
*/
getActorSocket: () => null,
issueCommand,
sendToChannel: channelIO.sendToChannel,
isAdminUser,
isLockdownAdminUser,
@@ -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;
@@ -6,10 +6,12 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
/*
Every client-side BrowserRouter entry point must also be an explicit HTTP
entry point. Including /ptz here lets direct loads and browser refreshes
receive the same rendered index document as navigation from the driver page.
entry point. Keeping this list aligned with webui/src/main.jsx lets direct
loads and browser refreshes receive the same rendered index document as
in-app navigation. The retired desktop composition is intentionally exposed
at /old; the removed /newdrive route is intentionally absent.
*/
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
try {
const html = await renderIndexHtml(req);
res.type('html').send(html);
@@ -1,178 +0,0 @@
// Fun Stats Service
// Purpose: Persists the running counters behind the social `rs` fun commands.
// Scope: Owns storage and clamping only; command handlers decide what a counter means.
const fs = require('fs');
const path = require('path');
const logger = require('../../globals/logger').child('funStatsService');
const { resolveDataPath } = require('../../helpers/dataPaths');
const STORE_PATH = resolveDataPath('fun-stats.json');
// Counters are additive and never authoritative for anything but bragging
// rights, so the ceiling only exists to keep a runaway loop from writing an
// unbounded integer into the store.
const MAX_COUNT = 1_000_000;
const MAX_LABEL_LENGTH = 64;
const ACTOR_COUNTERS = [
'bonksGiven',
'bonksTaken',
'hugsGiven',
'hugsTaken',
'slapsGiven',
'slapsTaken',
];
/*
This service deliberately keeps its own tiny JSON store rather than reusing
identityService.createJsonStore. Fun counters are keyed by an actor key that
spans transports (`user:<id>` for site chat, `discord:<id>` for Discord), and
a Discord id has no row in `users`, so it cannot live in `user_feature_state`
without violating that table's foreign key. Keeping storage local also means
the counters can be unit tested without opening the identity database.
*/
let cache = null;
function clampCount(value) {
const count = Number(value);
if (!Number.isFinite(count) || count <= 0) return 0;
return Math.min(Math.floor(count), MAX_COUNT);
}
function normalizeLabel(value) {
const label = String(value || '').trim().replace(/\s+/g, ' ');
if (!label) return null;
return label.slice(0, MAX_LABEL_LENGTH);
}
function normalizeActor(raw = {}) {
const actor = { label: normalizeLabel(raw.label) };
ACTOR_COUNTERS.forEach((key) => {
actor[key] = clampCount(raw[key]);
});
actor.updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : null;
return actor;
}
function normalizeStore(raw = {}) {
const actors = {};
const rawActors = raw && typeof raw.actors === 'object' && raw.actors ? raw.actors : {};
Object.keys(rawActors).forEach((key) => {
const actorKey = String(key || '').trim();
if (!actorKey) return;
actors[actorKey] = normalizeActor(rawActors[actorKey]);
});
const rovers = {};
const rawRovers = raw && typeof raw.rovers === 'object' && raw.rovers ? raw.rovers : {};
Object.keys(rawRovers).forEach((key) => {
const roverId = String(key || '').trim();
if (!roverId) return;
const entry = rawRovers[roverId] || {};
rovers[roverId] = {
pets: clampCount(entry.pets),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : null,
};
});
return { actors, rovers };
}
function loadState() {
if (cache) return cache;
try {
cache = normalizeStore(JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')));
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalizeStore({});
}
return cache;
}
function persistState(next) {
const normalized = normalizeStore(next);
try {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
} catch (err) {
// A failed write must not break the command that triggered it. The joke
// still lands; only the tally is lost.
logger.warn('Failed to persist fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalized;
return cache;
}
function getActorStats(actorKey) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
return { ...(loadState().actors[key] || normalizeActor({})) };
}
/*
`patch` is a map of counter name to increment. Unknown counter names are
ignored rather than stored so a typo in a handler cannot quietly create a
parallel counter that never shows up on the leaderboard.
*/
function bumpActorStats(actorKey, { label = null, ...patch } = {}) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
const state = loadState();
const current = state.actors[key] || normalizeActor({});
const next = { ...current };
const resolvedLabel = normalizeLabel(label);
if (resolvedLabel) next.label = resolvedLabel;
ACTOR_COUNTERS.forEach((counter) => {
const delta = Number(patch[counter]);
if (!Number.isFinite(delta) || delta === 0) return;
next[counter] = clampCount(current[counter] + delta);
});
next.updatedAt = Date.now();
persistState({ ...state, actors: { ...state.actors, [key]: next } });
return { ...next };
}
function listActorStats() {
const { actors } = loadState();
return Object.keys(actors).map((actorKey) => ({ actorKey, ...actors[actorKey] }));
}
function bumpRoverPets(roverId, by = 1) {
const id = String(roverId || '').trim();
if (!id) return 0;
const state = loadState();
const current = state.rovers[id] || { pets: 0, updatedAt: null };
const delta = Number(by);
const next = {
pets: clampCount(current.pets + (Number.isFinite(delta) ? delta : 0)),
updatedAt: Date.now(),
};
persistState({ ...state, rovers: { ...state.rovers, [id]: next } });
return next.pets;
}
function getRoverPets(roverId) {
const id = String(roverId || '').trim();
if (!id) return 0;
return loadState().rovers[id]?.pets || 0;
}
// Tests drive the store through a temporary SERVER_DATA_DIR, so they need a way
// to drop the module-level cache between cases.
function resetCacheForTests() {
cache = null;
}
module.exports = {
ACTOR_COUNTERS,
STORE_PATH,
getActorStats,
bumpActorStats,
listActorStats,
bumpRoverPets,
getRoverPets,
resetCacheForTests,
};
@@ -1,131 +0,0 @@
// Fun Stats Service Tests
// Purpose: Verifies counter persistence, clamping, and that a corrupt store degrades instead of throwing.
// Scope: Runs against a temporary SERVER_DATA_DIR so the real data directory is never touched.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fun-stats-test-'));
process.env.SERVER_DATA_DIR = dataDir;
const funStatsService = require('./index');
function reset() {
try {
fs.rmSync(funStatsService.STORE_PATH, { force: true });
} catch {
// A missing store is the normal starting state.
}
funStatsService.resetCacheForTests();
}
test('counters start at zero for an unknown actor', () => {
reset();
const stats = funStatsService.getActorStats('user:nobody');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.bonksTaken, 0);
assert.equal(stats.label, null);
});
test('bumping a counter accumulates and records the label', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
const stats = funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
assert.equal(stats.bonksGiven, 2);
assert.equal(stats.label, 'alice');
});
test('counters are independent of one another', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 3, hugsGiven: 1 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 3);
assert.equal(stats.hugsGiven, 1);
assert.equal(stats.slapsGiven, 0);
});
test('an unrecognized counter name is ignored rather than silently stored', () => {
reset();
funStatsService.bumpActorStats('user:alice', { notACounter: 5 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.notACounter, undefined);
});
test('state survives a cold read from disk', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 7 });
funStatsService.resetCacheForTests();
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 7);
});
test('an empty actor key is refused so anonymous bumps cannot share a bucket', () => {
reset();
funStatsService.bumpActorStats('', { bonksGiven: 1 });
assert.deepEqual(funStatsService.listActorStats(), []);
});
test('rover pets accumulate per rover', () => {
reset();
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 1);
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 2);
assert.equal(funStatsService.bumpRoverPets('rover-2', 1), 1);
assert.equal(funStatsService.getRoverPets('rover-1'), 2);
assert.equal(funStatsService.getRoverPets('unknown'), 0);
});
test('listActorStats returns every actor with their key', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
funStatsService.bumpActorStats('discord:4242', { label: 'dave', bonksGiven: 2 });
const keys = funStatsService.listActorStats().map((row) => row.actorKey).sort();
assert.deepEqual(keys, ['discord:4242', 'user:alice']);
});
test('negative and non-numeric deltas cannot drive a counter below zero', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 });
funStatsService.bumpActorStats('user:alice', { bonksGiven: -50 });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
funStatsService.bumpActorStats('user:alice', { bonksGiven: Number.NaN });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
});
test('labels are trimmed and length capped', () => {
reset();
const stats = funStatsService.bumpActorStats('user:alice', { label: ` ${'x'.repeat(200)} `, bonksGiven: 1 });
assert.equal(stats.label.length, 64);
});
test('a corrupt store file degrades to empty instead of throwing', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(funStatsService.STORE_PATH, '{not json at all', 'utf8');
funStatsService.resetCacheForTests();
assert.deepEqual(funStatsService.listActorStats(), []);
// And it must still be writable afterwards.
assert.equal(funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 }).bonksGiven, 1);
});
test('a store with the wrong shape is normalized rather than trusted', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(
funStatsService.STORE_PATH,
JSON.stringify({ actors: { 'user:alice': { bonksGiven: 'lots', label: 42 } }, rovers: 'nope' }),
'utf8',
);
funStatsService.resetCacheForTests();
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.label, '42');
assert.equal(funStatsService.getRoverPets('rover-1'), 0);
});
test.after(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
@@ -0,0 +1,101 @@
// Green Mode Service
// Purpose: Owns the temporary server-wide green visual mode and its tiny light workflow.
// Scope: Composes existing Home Assistant operations; it does not add policy to that service.
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('greenModeService');
const { sendAlert } = require('../alertService');
const homeAssistantService = require('../homeAssistantService');
const { modeEvents } = require('../modeManager');
const GREEN_MODE_COLOR = '#00ff00';
const greenModeEvents = new EventEmitter();
let enabled = false;
function isEnabled() {
return enabled;
}
async function setEnabled(nextValue, options = {}) {
const next = Boolean(nextValue);
if (enabled === next) return enabled;
if (next && homeAssistantService.enabled) {
/*
Lock first because the existing locked-on transition sets lights white.
Recoloring RGB lights afterward leaves them green while retaining the
established room-control lock, idle protection, and laser safety rules.
*/
await homeAssistantService.setLightsLockedOn(true, {
source: String(options?.source || 'greenMode:enable'),
});
const entities = homeAssistantService.getState()?.entities || [];
/*
RGB-capable lights become the requested solid green. Every other
configured room control, including white-only bulbs and switches, is
explicitly turned off so the physical room has one unambiguous effect.
These remain generic Home Assistant calls; that service does not know
that the operations belong to green mode.
*/
const results = await Promise.allSettled(
entities.map((entity) => (
entity?.supportsColor
? homeAssistantService.setLightColor(entity.id, GREEN_MODE_COLOR)
: homeAssistantService.setEntityState(entity.id, 'off', {
source: 'greenMode:non-rgb-off',
})
)),
);
const failures = results
.map((result, index) => ({ result, entityId: entities[index].id }))
.filter(({ result }) => result.status === 'rejected')
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
if (failures.length) {
logger.warn('Some room controls failed to enter green mode', { failures });
}
} else if (!next && homeAssistantService.enabled) {
// Disabling the visual mode simply releases the lock it created. Bulb
// colors remain untouched, matching the existing one-shot light behavior.
await homeAssistantService.setLightsLockedOn(false, {
source: String(options?.source || 'greenMode:disable'),
});
}
/*
Home Assistant is deliberately optional here. When it is not configured,
skipping the physical-room operations still allows the session theme,
CardFrame styling, alerts, commands, and timed reward to work normally.
The integration's generic lock state is also left untouched because there
are no server-managed room controls to lock.
*/
enabled = next;
logger.info('Green mode changed', {
enabled,
source: options?.source || 'unknown',
});
// Emit one shared server alert for every completed transition. Automatic
// access-mode shutdown uses this same function, so clients also receive the
// inactive notice when green mode ends without an explicit chat command.
sendAlert({
color: GREEN_MODE_COLOR,
title: 'Green mode',
message: enabled ? 'Green mode is active.' : 'Green mode is inactive.',
});
greenModeEvents.emit('change', enabled);
return enabled;
}
modeEvents.on('change', () => {
if (!enabled) return;
setEnabled(false, { source: 'modeGateReset' }).catch((err) => {
logger.warn('Failed to disable green mode on access-mode change', err.message);
});
});
module.exports = {
isEnabled,
setEnabled,
greenModeEvents,
};
+2 -1
View File
@@ -3,12 +3,13 @@
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fsp = require('fs/promises');
const path = require('path');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService');
const { getReplayHealthSnapshot } = require('../replayEngineV2');
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_STALE_MS = 5000;
@@ -80,6 +80,27 @@ function normalizeRgbColor(color) {
function createRuntimeEngine(deps) {
const { logger, enabled, haConfig, callHomeAssistantService } = deps;
async function turnOnLightAtFullBrightness(entityId, serviceData = {}) {
/*
Every server-owned interaction that turns on or changes a light must
also restore it to full brightness. Home Assistant remembers a bulb's
previous brightness, so sending only a color or color temperature can
otherwise make a light appear unexpectedly dim even though this service
requested an on-state.
Keeping this rule in one helper makes it apply consistently to ordinary
on commands, RGB changes, white-temperature changes, bulk operations,
random scenes, and lock-on behavior. brightness_pct is deliberately
written after the caller's service data so future call sites cannot
accidentally override the service-wide 100 percent requirement.
*/
await callHomeAssistantService('light', 'turn_on', {
entity_id: entityId,
...serviceData,
brightness_pct: 100,
});
}
function emitUpdate(getState) {
events.emit('update', getState());
}
@@ -143,7 +164,14 @@ function createRuntimeEngine(deps) {
const domain = String(meta.domain || (meta.type === 'light' ? 'light' : 'switch')).toLowerCase();
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
const source = String(options?.source || 'unknown');
await callHomeAssistantService(domain, service, { entity_id: entityId });
if (domain === 'light' && service === 'turn_on') {
await turnOnLightAtFullBrightness(entityId);
} else {
// Off commands and non-light domains do not accept a meaningful light
// brightness value, so their existing Home Assistant payload stays
// intentionally unchanged.
await callHomeAssistantService(domain, service, { entity_id: entityId });
}
logger.info('Issued Home Assistant command', { entityId, domain, service, source });
}
@@ -467,7 +495,7 @@ function createRuntimeEngine(deps) {
if (!runtime.connection) throw new Error('Home Assistant not connected');
const normalized = normalizeRgbColor(color);
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
await turnOnLightAtFullBrightness(entityId, { rgb_color: normalized });
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
}
@@ -481,7 +509,7 @@ function createRuntimeEngine(deps) {
const normalizedKelvin = Number.isFinite(nextKelvin)
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
: DEFAULT_WHITE_KELVIN;
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, color_temp_kelvin: normalizedKelvin });
await turnOnLightAtFullBrightness(entityId, { color_temp_kelvin: normalizedKelvin });
logger.info('Issued Home Assistant white command', { entityId, colorTempKelvin: normalizedKelvin });
}
@@ -0,0 +1,83 @@
// Home Assistant Runtime Engine Tests
// Purpose: Verifies the service-wide full-brightness rule for light commands.
// Scope: Exercises injected Home Assistant calls without opening a real connection or starting the server.
const assert = require('node:assert/strict');
const test = require('node:test');
const { createRuntimeEngine } = require('./runtimeEngine');
const { entityConfig, entityState, runtime } = require('./state');
function createHarness() {
const calls = [];
const engine = createRuntimeEngine({
enabled: true,
haConfig: { whiteKelvin: 4000 },
callHomeAssistantService: async (domain, service, serviceData) => {
calls.push({ domain, service, serviceData });
},
// These tests only verify outbound service payloads. A no-op logger keeps
// the harness faithful to the runtime dependency contract without adding
// unrelated output to the test run.
logger: {
info() {},
warn() {},
},
});
return { calls, engine };
}
test('light interactions force full brightness without changing switches or off commands', async (t) => {
const { calls, engine } = createHarness();
/*
runtimeEngine uses the shared entity registry populated from configuration
in production. Seed the smallest representative registry here and restore
the shared state afterward so this focused unit test cannot leak state into
other Home Assistant tests added later.
*/
entityConfig.clear();
entityState.clear();
entityConfig.set('light.room', { id: 'light.room', type: 'light', domain: 'light' });
entityConfig.set('switch.lamp', { id: 'switch.lamp', type: 'switch', domain: 'switch' });
runtime.connection = {};
t.after(() => {
entityConfig.clear();
entityState.clear();
runtime.connection = null;
});
await engine.setEntityState('light.room', 'on');
await engine.setLightColor('light.room', [12, 34, 56]);
await engine.setLightWhite('light.room', 4500);
await engine.setEntityState('light.room', 'off');
await engine.setEntityState('switch.lamp', 'on');
assert.deepEqual(calls, [
{
domain: 'light',
service: 'turn_on',
serviceData: { entity_id: 'light.room', brightness_pct: 100 },
},
{
domain: 'light',
service: 'turn_on',
serviceData: { entity_id: 'light.room', rgb_color: [12, 34, 56], brightness_pct: 100 },
},
{
domain: 'light',
service: 'turn_on',
serviceData: { entity_id: 'light.room', color_temp_kelvin: 4500, brightness_pct: 100 },
},
{
domain: 'light',
service: 'turn_off',
serviceData: { entity_id: 'light.room' },
},
{
domain: 'switch',
service: 'turn_on',
serviceData: { entity_id: 'switch.lamp' },
},
]);
});
@@ -12,6 +12,8 @@ const {
setVerified,
setDeterrence,
setMuted,
setUserPermission,
listRegisteredPermissions,
setFeatureState,
deleteFeatureState,
} = require('../identityService');
@@ -71,6 +73,11 @@ function ackHandler(socket, eventName, handler) {
io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({
users: listUsersForAdmin(),
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
@@ -112,6 +119,14 @@ io.on('connection', (socket) => {
}).id),
}));
ackHandler(socket, 'identityAdmin:setPermission', ({ userId, permissionKey, enabled }) => ({
user: getUserForAdmin(setUserPermission(userId, permissionKey, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
const normalized = normalizeFeaturePayload(namespace, value);
setFeatureState(userId, normalized.namespace, normalized.value);
+83 -50
View File
@@ -10,6 +10,7 @@ const Database = require('better-sqlite3');
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
const { resolveDataPath } = require('../../helpers/dataPaths');
const logger = require('../../globals/logger').child('identityService');
const { listRegisteredPermissions, requireRegisteredPermission } = require('./permissions');
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
@@ -17,7 +18,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 3;
const STORE_VERSION = 4;
const identityEvents = new EventEmitter();
let db = null;
@@ -169,10 +170,7 @@ function ensureSchema(conn) {
deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text,
audio_gain_boost_enabled integer not null default 0,
audio_gain_boost_at integer,
audio_gain_boost_by text
muted_by text
);
create table if not exists verification_requests (
@@ -208,6 +206,15 @@ function ensureSchema(conn) {
primary key (user_id, namespace)
);
create table if not exists user_permissions (
user_id text not null references users(id) on delete cascade,
permission_key text not null,
granted_at integer not null,
granted_by text,
primary key (user_id, permission_key)
);
create index if not exists idx_user_permissions_key on user_permissions(permission_key);
create table if not exists legacy_imports (
source text not null,
legacy_id text not null,
@@ -223,8 +230,9 @@ function ensureSchema(conn) {
/*
SQLite's `create table if not exists` leaves an existing table untouched.
Add the mute columns explicitly for installations created before store
version 2, and the audio gain boost columns for those created before store
version 3. The column-name check keeps every later startup idempotent.
version 2. Permission grants now live in their own normalized table, so the
obsolete audio-specific status columns are deliberately removed instead of
carrying old grants into the new capability system.
*/
const statusColumns = new Set(
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
@@ -238,15 +246,13 @@ function ensureSchema(conn) {
if (!statusColumns.has('muted_by')) {
conn.exec('alter table user_status add column muted_by text');
}
if (!statusColumns.has('audio_gain_boost_enabled')) {
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
}
if (!statusColumns.has('audio_gain_boost_at')) {
conn.exec('alter table user_status add column audio_gain_boost_at integer');
}
if (!statusColumns.has('audio_gain_boost_by')) {
conn.exec('alter table user_status add column audio_gain_boost_by text');
}
['audio_gain_boost_enabled', 'audio_gain_boost_at', 'audio_gain_boost_by'].forEach((column) => {
if (statusColumns.has(column)) conn.exec(`alter table user_status drop column ${column}`);
});
// Old personal fractions were identity-backed feature state. The replacement
// is intentionally browser-local, so retaining these unreachable rows would
// make the database page imply that they still control runtime behavior.
conn.prepare('delete from user_feature_state where namespace = ?').run('audioGains');
}
function createUser(conn = getDb(), ts = nowMs()) {
@@ -291,6 +297,13 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
conn.prepare('delete from user_known_ips where user_id = ?').run(sourceUserId);
conn.prepare('update verification_requests set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
conn.prepare('update legacy_imports set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
/*
Permissions describe the person, not one browser signal. Merging identities
therefore unions their grants before the source user is deleted; a conflict
keeps the target row and its original audit metadata.
*/
conn.prepare('update or ignore user_permissions set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
conn.prepare('delete from user_permissions where user_id = ?').run(sourceUserId);
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
ensureUserStatus(conn, targetUserId);
@@ -431,7 +444,6 @@ function setSocketIdentityState(socket, user, identity = {}) {
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
socket.data.isMuted = Boolean(user.deterrence?.muted);
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
}
function identifySocket(socket, payload = {}) {
@@ -523,11 +535,7 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
mutedAt: status.muted_at || null,
mutedBy: status.muted_by || null,
},
audioGainBoost: {
enabled: Boolean(status.audio_gain_boost_enabled),
at: status.audio_gain_boost_at || null,
by: status.audio_gain_boost_by || null,
},
permissions: getUserPermissions(id, { conn }),
features,
};
}
@@ -746,47 +754,76 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
}
/*
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
gain ceiling past the global admin gain settings. It stays a status column
rather than feature state so it can be filtered in SQL alongside the other
moderation flags and copied onto the socket at identify time.
Positive capabilities use normalized rows rather than feature-specific status
columns. This keeps moderation state focused and gives future permissions the
same audited grant/revoke path without another schema alteration.
*/
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
function setUserPermission(userId, permissionKey, { enabled = true, actor = null, at = nowMs() } = {}) {
const id = String(userId || '').trim();
if (!id) throw new Error('userId required');
ensureUserStatus(getDb(), id);
getDb().prepare(`
update user_status
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
const permission = requireRegisteredPermission(permissionKey);
const conn = getDb();
if (!conn.prepare('select 1 from users where id = ?').get(id)) throw new Error('User not found.');
if (enabled) {
conn.prepare(`
insert into user_permissions (user_id, permission_key, granted_at, granted_by)
values (?, ?, ?, ?)
on conflict(user_id, permission_key) do update set granted_at = excluded.granted_at, granted_by = excluded.granted_by
`).run(id, permission.key, at, actor ? String(actor) : null);
} else {
conn.prepare('delete from user_permissions where user_id = ? and permission_key = ?').run(id, permission.key);
}
identityEvents.emit('change', {
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
reason: enabled ? 'permission_granted' : 'permission_revoked',
userId: id,
permissionKey: permission.key,
});
conn.prepare('update users set updated_at = ? where id = ?').run(at, id);
return getUserById(id);
}
function getUserPermissions(userId, { conn = getDb() } = {}) {
const id = String(userId || '').trim();
if (!id) return [];
return conn.prepare(`
select permission_key as key, granted_at as grantedAt, granted_by as grantedBy
from user_permissions
where user_id = ?
order by permission_key
`).all(id);
}
function hasUserPermission(userId, permissionKey, { conn = getDb() } = {}) {
const id = String(userId || '').trim();
const permission = requireRegisteredPermission(permissionKey);
if (!id) return false;
return Boolean(conn.prepare('select 1 from user_permissions where user_id = ? and permission_key = ?').get(id, permission.key));
}
function listUsersWithPermission(permissionKey) {
const permission = requireRegisteredPermission(permissionKey);
const conn = getDb();
return conn.prepare('select user_id from user_permissions where permission_key = ? order by granted_at desc')
.all(permission.key)
.map((row) => getUserById(row.user_id, { conn, includeFeatures: false }))
.filter(Boolean);
}
function isVerified(socket) {
return Boolean(socket?.data?.isVerified);
}
function hasAudioGainBoost(socket) {
return Boolean(socket?.data?.hasAudioGainBoost);
}
function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred);
}
function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
const conn = getDb();
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
const where = [];
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
if (where.length) sql += ` where ${where.join(' and ')}`;
sql += ' order by users.updated_at desc';
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
@@ -808,9 +845,7 @@ function userToLegacyIdentityEntry(user) {
muted: Boolean(user.deterrence?.muted),
mutedAt: user.deterrence?.mutedAt || null,
mutedBy: user.deterrence?.mutedBy || null,
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
audioGainBoostAt: user.audioGainBoost?.at || null,
audioGainBoostBy: user.audioGainBoost?.by || null,
permissions: (user.permissions || []).map((permission) => permission.key),
};
}
@@ -826,10 +861,6 @@ function listMutedUsers() {
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
}
function listAudioGainBoostUsers() {
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
}
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
const value = String(selector || '').trim();
if (!value) return { error: 'selector_required' };
@@ -1084,14 +1115,16 @@ module.exports = {
setVerified,
setDeterrence,
setMuted,
setAudioGainBoost,
setUserPermission,
getUserPermissions,
hasUserPermission,
listUsersWithPermission,
listRegisteredPermissions,
isVerified,
isDeterred,
hasAudioGainBoost,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
listAudioGainBoostUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
createJsonStore,
@@ -0,0 +1,30 @@
// Identity Permission Registry
// Purpose: Defines every positive capability that can be granted to a canonical user.
// Scope: Keeps stable database keys and operator-facing descriptions centralized so services and admin tools cannot invent mismatched permission names.
const USER_PERMISSIONS = Object.freeze({
'audio.personalAdjustment': Object.freeze({
key: 'audio.personalAdjustment',
commandName: 'audio-adjustment',
label: 'Personal audio adjustment',
description: 'Allows personal horn, text-to-speech, and microphone volume adjustments.',
}),
});
function listRegisteredPermissions() {
return Object.values(USER_PERMISSIONS).map((permission) => ({ ...permission }));
}
function requireRegisteredPermission(permissionKey) {
const key = String(permissionKey || '').trim().toLowerCase();
const permission = Object.values(USER_PERMISSIONS).find((entry) => (
entry.key.toLowerCase() === key || entry.commandName.toLowerCase() === key
));
if (!permission) throw new Error('Unknown user permission.');
return permission;
}
module.exports = {
USER_PERMISSIONS,
listRegisteredPermissions,
requireRegisteredPermission,
};
@@ -0,0 +1,82 @@
// Identity Permission Storage Tests
// Purpose: Verifies normalized grants, registry validation, and the intentionally empty replacement for legacy audio boost flags.
// Scope: Uses an isolated temporary data directory and never opens the development identity database.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rover-identity-permissions-'));
process.env.SERVER_DATA_DIR = testDataDir;
/*
Seed the exact legacy concern this redesign removes. Opening identityService
must preserve the real moderation fields while dropping all old boost grants
instead of translating them into the new permission table.
*/
const legacyDb = new Database(path.join(testDataDir, 'identity.sqlite'));
legacyDb.exec(`
create table users (
id text primary key,
created_at integer not null,
updated_at integer not null,
last_seen_at integer
);
create table user_status (
user_id text primary key references users(id) on delete cascade,
verified_enabled integer not null default 0,
verified_at integer,
verified_by text,
deterrence_enabled integer not null default 0,
deterrence_reason text,
deterrence_at integer,
deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text,
audio_gain_boost_enabled integer not null default 0,
audio_gain_boost_at integer,
audio_gain_boost_by text
);
`);
legacyDb.close();
const identityService = require('./index');
test.after(() => {
identityService.getDb().close();
fs.rmSync(testDataDir, { recursive: true, force: true });
});
test('legacy audio boost columns are removed and normalized permissions are created empty', () => {
const db = identityService.getDb();
const statusColumns = db.prepare('pragma table_info(user_status)').all().map((column) => column.name);
assert.doesNotMatch(statusColumns.join(','), /audio_gain_boost/);
assert.ok(db.prepare("select 1 from sqlite_master where type = 'table' and name = 'user_permissions'").get());
});
test('registered permissions can be granted, listed, queried, and revoked', () => {
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_11111111111111111111111111111111' });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
identityService.setUserPermission(userId, 'audio-adjustment', { enabled: true, actor: 'test-admin', at: 1234 });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), true);
assert.deepEqual(identityService.getUserPermissions(userId), [{
key: 'audio.personalAdjustment',
grantedAt: 1234,
grantedBy: 'test-admin',
}]);
assert.equal(identityService.listUsersWithPermission('audio-adjustment')[0].id, userId);
identityService.setUserPermission(userId, 'audio.personalAdjustment', { enabled: false });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
});
test('unknown permission keys cannot be persisted', () => {
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_22222222222222222222222222222222' });
assert.throws(
() => identityService.setUserPermission(userId, 'made.up.permission', { enabled: true }),
/Unknown user permission/,
);
});
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const yaml = require('js-yaml');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { buildMediaMtxConfig } = require('./config');
function createMediaMtxSupervisor(deps) {
@@ -13,7 +14,7 @@ function createMediaMtxSupervisor(deps) {
serverPort,
logger,
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
runtimeDir = process.env.MULTIROVER_RUNTIME_DIR || '/run/multirover',
configPath = resolveDataPath('mediamtx.yml'),
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
spawnProcess = spawn,
} = deps;
@@ -39,14 +40,28 @@ function createMediaMtxSupervisor(deps) {
function start() {
if (child) return child;
const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
const configPath = path.join(runtimeDir, 'mediamtx.yml');
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 });
fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
/*
Generated MediaMTX state belongs beside the server's other owned data. Using the shared
data-path helper honors SERVER_DATA_DIR as well as the normal server/data directory and
avoids introducing a systemd-created /run directory with separate permission rules.
*/
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, yaml.dump(generatedConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
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 });
}
});
+94
View File
@@ -9,6 +9,7 @@ const { isFeatureEnabled } = require('../../helpers/features');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const { sendAlert } = require('../alertService');
const {
homeAssistantEvents,
getRawEntitySnapshot,
@@ -31,6 +32,11 @@ function normalizeDeviceName(value) {
const device = normalizeDeviceName(neatoConfig.device);
const RESUME_DELAY_MS = 3000;
const ALERT_COLOR = '#a855f7';
// BrainSlug exposes these exact select values for Gen 3 robots. Keeping the
// allowlist on the server prevents arbitrary Home Assistant select options from
// being submitted by a modified browser while preserving BrainSlug's casing.
const NAVIGATION_MODES = Object.freeze(['Normal', 'Gentle', 'Deep', 'Quick']);
function entityId(domain, suffix) {
if (!device) return '';
@@ -61,8 +67,27 @@ const ENTITY_IDS = {
robotError: entityId('sensor', 'robot_error'),
robotAlert: entityId('sensor', 'robot_alert'),
},
selects: {
navigationMode: entityId('select', 'navigation_mode'),
},
};
// Alert Feed coverage is intentionally limited to the raw robot lifecycle and
// issue fields requested for Neato. Battery and charger telemetry poll often and
// would create noise without representing a useful robot status transition.
const ALERT_ENTITIES = Object.freeze([
{ title: 'Neato UI state', entityId: ENTITY_IDS.textSensors.uiState },
{ title: 'Neato robot state', entityId: ENTITY_IDS.textSensors.robotState },
{ title: 'Neato robot alert', entityId: ENTITY_IDS.textSensors.robotAlert },
{ title: 'Neato robot error', entityId: ENTITY_IDS.textSensors.robotError },
{ title: 'Neato external power', entityId: ENTITY_IDS.binarySensors.extPowerPresent },
]);
// Each entity establishes its own baseline because ESPHome entities can become
// available on different snapshots. A Map also distinguishes "not observed yet"
// from a legitimate raw state string without inventing a sentinel state value.
const alertBaselines = new Map();
function readRaw(entityIdValue) {
if (!entityIdValue) return null;
return getRawEntitySnapshot(entityIdValue);
@@ -94,6 +119,32 @@ function isEntityAvailable(entityIdValue) {
return state !== 'unavailable';
}
function emitRawStateAlerts() {
for (const { title, entityId: entityIdValue } of ALERT_ENTITIES) {
const raw = readState(entityIdValue);
const normalized = String(raw ?? '').trim().toLowerCase();
// Missing and unavailable values commonly occur while Home Assistant or the
// ESPHome device reconnects. Ignoring them preserves the last real baseline
// and prevents connection churn from becoming misleading Neato activity.
if (!normalized || normalized === 'unavailable' || normalized === 'unknown') continue;
const rawMessage = String(raw);
if (!alertBaselines.has(entityIdValue)) {
// The first real value is startup state, not a transition caused while the
// service was watching, so record it without creating an Alert Feed toast.
alertBaselines.set(entityIdValue, rawMessage);
continue;
}
if (alertBaselines.get(entityIdValue) === rawMessage) continue;
alertBaselines.set(entityIdValue, rawMessage);
// The title provides field context, while the message remains exactly the
// new Home Assistant state with no friendly translation or previous value.
sendAlert({ color: ALERT_COLOR, title, message: rawMessage });
}
}
function requiredEntityIds() {
return [
ENTITY_IDS.buttons.start,
@@ -146,6 +197,14 @@ function buildState() {
entityId: ENTITY_IDS.buttons.powerCycle,
available: hasEntity(ENTITY_IDS.buttons.powerCycle),
},
navigationMode: {
entityId: ENTITY_IDS.selects.navigationMode,
available: isEntityAvailable(ENTITY_IDS.selects.navigationMode),
value: readState(ENTITY_IDS.selects.navigationMode),
// The browser receives the supported choices through the session contract
// instead of duplicating BrainSlug-specific values in the presentation layer.
options: NAVIGATION_MODES,
},
};
const batteryPercentValue = parseNumber(readState(ENTITY_IDS.sensors.batteryPercent));
@@ -199,6 +258,7 @@ if (featureEnabled) {
*/
homeAssistantEvents.on('snapshot', () => {
emitUpdate();
emitRawStateAlerts();
});
homeAssistantEvents.on('status', () => {
@@ -255,6 +315,29 @@ async function powerCycle() {
await pressButton(ENTITY_IDS.buttons.powerCycle, 'powercucle');
}
async function setNavigationMode(mode) {
assertConfiguredAndConnected();
const normalizedMode = String(mode || '').trim();
if (!NAVIGATION_MODES.includes(normalizedMode)) {
throw new Error('Invalid Neato navigation mode');
}
if (!isEntityAvailable(ENTITY_IDS.selects.navigationMode)) {
throw new Error('Neato action unavailable: navigation_mode');
}
// ESPHome implements Navigation Mode as a Home Assistant select entity, so
// select_option is the native service call and avoids sending raw UART commands.
await callHomeAssistantService('select', 'select_option', {
entity_id: ENTITY_IDS.selects.navigationMode,
option: normalizedMode,
});
logger.info('Issued Neato action', {
action: 'set_navigation_mode',
entityId: ENTITY_IDS.selects.navigationMode,
option: normalizedMode,
});
}
function getState() {
cachedState = buildState();
return cachedState;
@@ -328,6 +411,16 @@ if (featureEnabled) {
cb({ error: err.message });
}
});
socket.on('neato:setNavigationMode', async ({ mode } = {}, cb = () => {}) => {
try {
assertFeatureAccess();
await setNavigationMode(mode);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
});
} else {
logger.info('Neato disabled by config');
@@ -342,5 +435,6 @@ module.exports = {
locateRobot,
clearErrors,
powerCycle,
setNavigationMode,
neatoEvents: events,
};
@@ -1,174 +0,0 @@
// Operator Fun Command Helpers
// Purpose: Shared actor identity, target lookup, and deterministic randomness for the fun commands.
// Scope: No side effects; every function here is safe to call before permission checks pass.
const { normalizeSearchText, normalizeText, resolveRoverSelector } = require('./resolvers');
// Echoed user text is capped so a fun command cannot be used to shout a wall of
// text into every bridged Discord channel.
const MAX_ECHO_LENGTH = 180;
const PLAIN_MENTIONS = { parse: [], repliedUser: false };
/*
Fun counters have to survive across transports, so they are keyed by a stable
identity rather than a connection. Site chat resolves to the identity user id
that moderation already uses; Discord has no row in that database, so it gets
its own key space. An unidentified site socket falls back to its socket id,
which means its tally resets on reconnect acceptable for a joke counter, and
much better than crediting every anonymous visitor to one shared bucket.
*/
function buildActorKey(request) {
const transport = normalizeText(request?.transport) || 'unknown';
if (transport === 'discord') {
const discordId = normalizeText(request?.actor?.id);
return discordId ? `discord:${discordId}` : null;
}
const userId = normalizeText(request?.actor?.userId);
if (userId) return `user:${userId}`;
const socketId = normalizeText(request?.actor?.id);
return socketId ? `socket:${socketId}` : null;
}
function actorLabel(request) {
return normalizeText(request?.actor?.label) || 'someone';
}
/*
Collapses every connected socket for one person onto their canonical user id so
extra browser tabs cannot make a target look ambiguous. Mirrors the same
approach the deter command uses for moderation targets.
*/
function findOnlineUsers(io, getNickname, selector) {
const normalizedSelector = normalizeSearchText(selector);
if (!normalizedSelector) return [];
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return [];
const byUserId = new Map();
sockets.forEach((socket) => {
const nickname = getNickname?.(socket);
if (normalizeSearchText(nickname) !== normalizedSelector) return;
const userId = normalizeText(socket?.data?.userId);
const key = userId || `socket:${normalizeText(socket?.id)}`;
if (!key) return;
if (!byUserId.has(key)) {
byUserId.set(key, { userId: userId || null, nickname: normalizeText(nickname), socket });
}
});
return Array.from(byUserId.values());
}
/*
A fun command should still work when the target is not a real user bonking
"the dishwasher" is half the point. So an unmatched selector is not an error:
it becomes a plain label and simply credits nobody's tally. Ambiguity is
treated the same way, because guessing which of two identical nicknames took
the hit would be worse than crediting neither.
*/
function resolveFunTarget({ io, getNickname, selector }) {
const label = clampEcho(selector);
if (!label) return null;
const matches = findOnlineUsers(io, getNickname, selector);
if (matches.length === 1) {
const [match] = matches;
return {
label: match.nickname || label,
actorKey: match.userId ? `user:${match.userId}` : null,
socket: match.socket || null,
online: true,
};
}
return { label, actorKey: null, socket: null, online: false };
}
/*
Rover-scoped fun commands accept an explicit rover name and otherwise fall back
to whichever rover the caller is already attached to. Discord has no socket
behind it, so the fallback simply is not available there and the caller is asked
to name a rover rather than having one chosen for them.
*/
function createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix = 'rs' }) {
return function resolveTargetRover(selector, action = 'pet') {
const query = normalizeText(selector);
if (query) {
const resolved = resolveRoverSelector(query, rovers);
if (resolved.error) return { error: resolved.error };
return { id: resolved.id, name: resolved.label || resolved.id, record: resolved.record };
}
const socket = getActorSocket?.() || null;
if (!socket) return { error: `Name a rover: \`${commandPrefix} ${action} <rover>\`` };
// getPrimaryRoverForSocket takes a socket id and returns a rover id string.
const roverId = normalizeText(roverManager?.getPrimaryRoverForSocket?.(socket.id));
if (!roverId) return { error: 'You are not on a rover right now. Name one instead.' };
const record = rovers.get(roverId) || null;
return { id: roverId, name: record?.meta?.name || roverId, record, socket };
};
}
function clampEcho(value) {
const text = normalizeText(value).replace(/\s+/g, ' ');
if (!text) return '';
if (text.length <= MAX_ECHO_LENGTH) return text;
return `${text.slice(0, MAX_ECHO_LENGTH - 1)}`;
}
/*
FNV-1a. Fun commands that judge something `ship`, `rate`, `8ball` use a
hash of the input instead of Math.random so the same question always gets the
same answer. Re-rolling until you like the verdict is not funny; a server that
stubbornly insists your ship rating is 4% is.
*/
function hashSeed(value) {
const text = normalizeSearchText(value);
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
function pickBySeed(list, seed) {
const items = Array.isArray(list) ? list : [];
if (!items.length) return null;
return items[seed % items.length];
}
// Order-independent so `rs ship a b` and `rs ship b a` agree with each other.
function pairSeed(left, right) {
const pair = [normalizeSearchText(left), normalizeSearchText(right)].sort();
return hashSeed(pair.join(' '));
}
function percentFromSeed(seed) {
return seed % 101;
}
function ordinal(count) {
const value = Number(count) || 0;
const mod100 = value % 100;
if (mod100 >= 11 && mod100 <= 13) return `${value}th`;
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[value % 10] || 'th';
return `${value}${suffix}`;
}
module.exports = {
MAX_ECHO_LENGTH,
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
clampEcho,
createRoverResolver,
findOnlineUsers,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
};
@@ -1,162 +0,0 @@
// Operator Fun Helper Tests
// Purpose: Locks down actor identity, target resolution, and the deterministic seeding the fun commands depend on.
// Scope: Pure helpers plus in-memory socket doubles; nothing here touches the fun stats store.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildActorKey,
clampEcho,
createRoverResolver,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
MAX_ECHO_LENGTH,
} = require('./funHelpers');
function socket(id, userId, nickname) {
return { id, data: { userId, nickname } };
}
function harness(sockets = []) {
return {
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
getNickname: (entry) => entry?.data?.nickname || '',
};
}
test('site chat keys on the identity user id, not the socket', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1', userId: 'u-alice' } }),
'user:u-alice',
);
});
test('an unidentified site socket falls back to its socket id', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1' } }),
'socket:socket-1',
);
});
test('discord actors get their own key space so ids cannot collide with identity ids', () => {
assert.equal(buildActorKey({ transport: 'discord', actor: { id: '4242' } }), 'discord:4242');
});
test('an actor with no usable id at all is rejected rather than sharing a bucket', () => {
assert.equal(buildActorKey({ transport: 'web-chat', actor: {} }), null);
assert.equal(buildActorKey({ transport: 'discord', actor: {} }), null);
});
test('a single online nickname resolves to that user and credits their tally', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'BOB' });
assert.equal(resolved.label, 'bob');
assert.equal(resolved.actorKey, 'user:u-bob');
assert.equal(resolved.online, true);
});
test('multiple tabs for one person do not make the target ambiguous', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-bob', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, 'user:u-bob');
});
test('an unmatched selector still works but credits nobody', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'the dishwasher' });
assert.equal(resolved.label, 'the dishwasher');
assert.equal(resolved.actorKey, null);
assert.equal(resolved.online, false);
});
test('two different people sharing a nickname credit neither', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-other', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, null);
});
test('echoed text is length capped so a fun command cannot shout a wall of text', () => {
const long = 'a'.repeat(500);
const clamped = clampEcho(long);
assert.equal(clamped.length, MAX_ECHO_LENGTH);
assert.ok(clamped.endsWith('…'));
});
test('ship is order independent so both spellings agree', () => {
assert.equal(pairSeed('alice', 'bob'), pairSeed('bob', 'alice'));
});
test('seeded verdicts are stable, so a rating cannot be rerolled by asking again', () => {
const first = percentFromSeed(pairSeed('alice', 'bob'));
const second = percentFromSeed(pairSeed('alice', 'bob'));
assert.equal(first, second);
assert.ok(first >= 0 && first <= 100);
});
test('hashSeed ignores case and surrounding whitespace', () => {
assert.equal(hashSeed(' Will It Dock '), hashSeed('will it dock'));
});
test('pickBySeed stays in range and tolerates an empty list', () => {
const list = ['a', 'b', 'c'];
for (let seed = 0; seed < 20; seed += 1) {
assert.ok(list.includes(pickBySeed(list, seed)));
}
assert.equal(pickBySeed([], 5), null);
});
test('ordinal handles the teens correctly', () => {
assert.equal(ordinal(1), '1st');
assert.equal(ordinal(2), '2nd');
assert.equal(ordinal(3), '3rd');
assert.equal(ordinal(4), '4th');
assert.equal(ordinal(11), '11th');
assert.equal(ordinal(12), '12th');
assert.equal(ordinal(13), '13th');
assert.equal(ordinal(21), '21st');
assert.equal(ordinal(111), '111th');
});
test('an explicit rover name wins over whatever the caller is attached to', () => {
const rovers = new Map([
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
getActorSocket: () => ({ id: 's1' }),
});
assert.equal(resolve('Roomba Two').id, 'rover-2');
});
test('with no rover named the caller\'s current rover is used', () => {
const rovers = new Map([['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }]]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: (socketId) => (socketId === 's1' ? 'rover-1' : null) },
getActorSocket: () => ({ id: 's1' }),
});
const resolved = resolve('');
assert.equal(resolved.id, 'rover-1');
assert.equal(resolved.name, 'Roomba One');
});
test('without a socket the caller is asked to name a rover instead of one being chosen', () => {
const resolve = createRoverResolver({
rovers: new Map(),
roverManager: {},
getActorSocket: () => null,
commandPrefix: 'rs',
});
assert.match(resolve('', 'pet').error, /Name a rover/);
});
@@ -1,285 +0,0 @@
// Operator Fun Rover Commands
// Purpose: Implements the fun commands that actually make the fleet or the room do something (honk, boo, disco, spin, vibecheck).
// Scope: Every handler here re-checks control and feature gating itself, because issueCommand bypasses the socket command guards.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
createRoverResolver,
hashSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
// Durations are deliberately short and are also bounded rover-side: roverd
// enforces its own horn MaxDuration, so a lost stop command cannot leave a horn
// sounding forever.
const HONK_MS = 600;
const HONK_FREQ_HZ = 440;
const SPIN_MS = 1200;
const SPIN_SPEED = 120;
const DISCO_MS = 12 * 1000;
const DISCO_TICK_MS = 750;
const HONK_ACTOR_COOLDOWN_MS = 20 * 1000;
const HONK_ROVER_COOLDOWN_MS = 8 * 1000;
const BOO_COOLDOWN_MS = 30 * 1000;
const SPIN_COOLDOWN_MS = 25 * 1000;
const DISCO_COOLDOWN_MS = 2 * 60 * 1000;
const VIBECHECK_COOLDOWN_MS = 5 * 1000;
/*
Taunts are a fixed list rather than caller-supplied text on purpose. `boo` puts
audio out of a speaker in a room full of people, so letting it read arbitrary
input would turn a joke command into an unmoderated TTS channel aimed at
whoever is nearest the rover.
*/
const BOO_TAUNTS = [
'Boo.', 'Your driving is being reviewed.', 'That was a choice.',
'The wall was right there.', 'Someone in chat is laughing at you.',
'I have seen better parking from the Neato.', 'Boo. Respectfully.',
'This is a citizen\'s arrest.', 'Turn left. No, the other left.',
];
const VIBE_VERDICTS = [
'immaculate', 'acceptable', 'questionable', 'concerning', 'dire', 'unwell',
];
function describeBattery(batteryState) {
const display = Number(batteryState?.percentDisplay);
if (Number.isFinite(display)) return `${Math.round(display)}%`;
const percent = Number(batteryState?.percent);
if (Number.isFinite(percent)) return `${Math.round(percent * 100)}%`;
return 'unknown';
}
function createFunRoverCommands({
io,
rovers,
roverManager,
getNickname,
getActiveDrivers,
getActorSocket,
issueCommand,
homeAssistantService,
isFeatureEnabled,
sanitizeMentions,
cooldowns,
logger,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
/*
issueCommand is the raw rover transport: it performs none of the ownership,
deterrence, or private-safety checks that the socket `command` handler applies.
Any fun command that moves hardware therefore has to prove control here, which
also means these commands are inherently site-chat only a Discord message has
no socket and so can never satisfy canDrive.
*/
function requireDriveControl(action, selector) {
const socket = getActorSocket?.() || null;
if (!socket) {
return { error: `\`${commandPrefix} ${action}\` only works from site chat, where you can actually be driving.` };
}
const rover = resolveTargetRover(selector, action);
if (rover.error) return { error: rover.error };
if (!roverManager?.canDrive?.(rover.id, socket)) {
return { error: `You need control of ${rover.name} to do that.` };
}
return { rover, socket };
}
function safeIssue(roverId, payload, context) {
try {
issueCommand(roverId, payload);
return true;
} catch (err) {
// Deferred stop commands routinely land after a rover drops off. That is
// expected, not an incident, so it is logged at debug volume and swallowed.
logger?.warn?.('Fun command could not reach rover', { roverId, context, error: err.message });
return false;
}
}
async function handleHonk(message, tokens = []) {
const control = requireDriveControl('honk', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover } = control;
if (rover.record?.meta?.horn?.enabled === false) {
return reply(message, `${rover.name} has no horn fitted.`);
}
const gated = gate(message, 'honk', HONK_ACTOR_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
// A second, rover-scoped window stops a group of drivers taking turns to
// honk the same rover continuously while each stays inside their own limit.
const roverWait = cooldowns.consume(`honk:rover:${rover.id}`, HONK_ROVER_COOLDOWN_MS);
if (roverWait > 0) {
return reply(message, `${rover.name} was just honked. Give it ${describeWait(roverWait)}.`);
}
if (!safeIssue(rover.id, { type: 'horn', horn: { action: 'start', waveform: 'sine', freqs: [HONK_FREQ_HZ] } }, 'honk:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'horn', horn: { action: 'stop' } }, 'honk:stop'), HONK_MS);
return reply(message, `📢 HONK. (${rover.name})`);
}
async function handleSpin(message, tokens = []) {
const control = requireDriveControl('spin', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover, socket } = control;
const gated = gate(message, 'spin', SPIN_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const maxWheelSpeed = Number(rover.record?.meta?.maxWheelSpeed);
const speed = Math.max(1, Math.min(SPIN_SPEED, Number.isFinite(maxWheelSpeed) && maxWheelSpeed > 0 ? maxWheelSpeed : SPIN_SPEED));
let driveDirect = { left: speed, right: -speed };
/*
Private rovers can carry a reduced speed ceiling that the socket path would
normally apply. Applying it explicitly keeps a fun command from being the one
way to exceed a limit an admin set for a specific rover.
*/
const safeDrive = roverManager?.applyPrivateDriveSafety?.(rover.id, socket, driveDirect);
if (safeDrive) driveDirect = safeDrive;
if (!safeIssue(rover.id, { type: 'drive', driveDirect }, 'spin:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'drive', driveDirect: { left: 0, right: 0 } }, 'spin:stop'), SPIN_MS);
return reply(message, `🌀 ${rover.name} is doing a spin.`);
}
async function handleBoo(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
const gated = gate(message, 'boo', BOO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const resolved = resolveFunTarget({ io, getNickname, selector });
if (!resolved) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
if (!resolved.online || !resolved.socket) {
return reply(message, `${resolved.label} is not here to be booed.`);
}
// Boo lands on the rover the target is actually driving, so it needs the
// active-driver map rather than merely which rovers they are watching.
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === resolved.socket.id) || null;
if (!roverId) return reply(message, `${resolved.label} is not driving anything right now.`);
const record = rovers.get(String(roverId));
const roverName = record?.meta?.name || roverId;
if (record?.meta?.audio?.ttsEnabled === false) {
return reply(message, `${roverName} cannot speak.`);
}
const taunt = pickBySeed(BOO_TAUNTS, hashSeed(`${gated.actorKey}:${resolved.label}`));
if (!safeIssue(roverId, { type: 'tts', tts: { text: taunt, speak: true, engine: 'chromegtts' } }, 'boo')) {
return reply(message, `${roverName} is offline.`);
}
return reply(message, `👻 Booed ${resolved.label} through ${roverName}.`);
}
async function handleDisco(message) {
if (!homeAssistantService || !isFeatureEnabled?.('homeAssistant')) {
return reply(message, 'Room light controls are unavailable.');
}
// An admin lock on the room lights is a policy boundary. Disco is a scene
// change like `rs lights on`, so it must not be the one command that ignores it.
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
if (lightPolicy.locked) {
return reply(message, 'Room lights are locked. No disco.');
}
const gated = gate(message, 'disco', DISCO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const setAll = homeAssistantService.setAllControllableEntitiesState;
if (typeof setAll !== 'function') {
return reply(message, 'Room light controls are unavailable.');
}
const endsAt = Date.now() + DISCO_MS;
let on = false;
/*
Held in a local interval rather than the rewards effect store because a
disco is short and disposable. Nothing needs to survive a restart, and the
final tick always restores the lights to on.
*/
const timer = setInterval(() => {
if (Date.now() >= endsAt) {
clearInterval(timer);
Promise.resolve(setAll('on')).catch((err) => {
logger?.warn?.('Disco could not restore lights', { error: err.message });
});
return;
}
on = !on;
Promise.resolve(setAll(on ? 'on' : 'off')).catch((err) => {
logger?.warn?.('Disco tick failed', { error: err.message });
});
}, DISCO_TICK_MS);
return reply(message, `🪩 Disco for ${Math.round(DISCO_MS / 1000)} seconds. Started by ${gated.label}.`);
}
async function handleVibecheck(message, tokens = []) {
const gated = gate(message, 'vibecheck', VIBECHECK_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'vibecheck');
if (rover.error) return reply(message, rover.error);
const record = rover.record || rovers.get(rover.id) || null;
const battery = describeBattery(record?.batteryState);
const offline = !record?.ws;
const locked = Boolean(record?.locked);
const urgent = Boolean(record?.batteryState?.urgentActive);
const warn = Boolean(record?.batteryState?.warnActive);
let verdict;
if (offline) verdict = 'nonexistent — it is offline';
else if (urgent) verdict = 'dying';
else if (warn) verdict = 'running low';
else if (locked) verdict = 'locked out and sulking';
else verdict = pickBySeed(VIBE_VERDICTS, hashSeed(`${rover.id}:${battery}`));
return reply(message, `🔍 ${rover.name}: vibes are **${verdict}**. Battery ${battery}.`);
}
return {
honk: handleHonk,
boo: handleBoo,
disco: handleDisco,
spin: handleSpin,
vibecheck: handleVibecheck,
};
}
module.exports = { createFunRoverCommands, describeBattery };
@@ -1,297 +0,0 @@
// Operator Fun Rover Command Tests
// Purpose: Verifies the control, feature, and lock checks the hardware-backed fun commands must make themselves.
// Scope: issueCommand, roverManager, and Home Assistant are all doubles; no real rover or timer is involved.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunRoverCommands, describeBattery } = require('./funRover');
const { createCooldownGate } = require('../cooldowns');
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
function createHarness({
canDrive = true,
socket = ALICE,
hornEnabled = true,
ttsEnabled = true,
online = true,
maxWheelSpeed = 300,
privateSafetyDrive = null,
activeDrivers = {},
homeAssistantService = null,
featureEnabled = false,
sockets = [ALICE, BOB],
} = {}) {
const issued = [];
const record = {
id: 'rover-1',
ws: online ? {} : null,
locked: false,
meta: {
name: 'Roomba One',
maxWheelSpeed,
horn: { enabled: hornEnabled },
audio: { ttsEnabled },
},
batteryState: { percentDisplay: 74, warnActive: false, urgentActive: false },
};
const rovers = new Map([['rover-1', record]]);
const handlers = createFunRoverCommands({
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
rovers,
roverManager: {
canDrive: () => canDrive,
getPrimaryRoverForSocket: () => 'rover-1',
applyPrivateDriveSafety: () => privateSafetyDrive,
},
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
getActorSocket: () => socket,
issueCommand: (roverId, payload) => {
if (!record.ws) throw new Error('Rover offline');
issued.push({ roverId, ...payload });
return 'cmd-1';
},
homeAssistantService,
isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
cooldowns: createCooldownGate(),
logger: { warn: () => {} },
config: { commands: { prefix: 'rs' } },
});
return { handlers, issued, record, rovers };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
test('honk starts the horn and schedules a stop', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness();
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /HONK/);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start']);
// The stop is deferred, so nothing has released the horn yet.
t.mock.timers.tick(1000);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start', 'stop']);
});
test('honk is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /need control of Roomba One/);
assert.equal(issued.length, 0);
});
test('honk is refused from a transport with no socket, so Discord cannot drive hardware', async () => {
const { handlers, issued } = createHarness({ socket: null });
const msg = message({ id: '4242', label: 'DiscordUser' });
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /only works from site chat/);
assert.equal(issued.length, 0);
});
test('honk is refused on a rover with no horn fitted', async () => {
const { handlers, issued } = createHarness({ hornEnabled: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /no horn fitted/);
assert.equal(issued.length, 0);
});
test('a second driver cannot bypass the rover cooldown with their own fresh actor window', async () => {
const { handlers, issued } = createHarness();
await handlers.honk(message(), []);
const other = message({ id: 's2', userId: 'u-bob', label: 'bob' });
await handlers.honk(other, []);
assert.match(other.replies[0].content, /was just honked/);
// Only the first honk reached the rover.
assert.equal(issued.filter((entry) => entry.horn?.action === 'start').length, 1);
});
test('an offline rover reports offline instead of claiming a honk happened', async () => {
const { handlers } = createHarness({ online: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /is offline/);
});
test('spin clamps to the rover wheel speed ceiling and always stops itself', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness({ maxWheelSpeed: 50 });
const msg = message();
await handlers.spin(msg, []);
assert.equal(issued[0].driveDirect.left, 50);
assert.equal(issued[0].driveDirect.right, -50);
t.mock.timers.tick(2000);
assert.deepEqual(issued[1].driveDirect, { left: 0, right: 0 });
});
test('spin honours a private rover safety override rather than bypassing it', async () => {
const { handlers, issued } = createHarness({ privateSafetyDrive: { left: 20, right: -20 } });
await handlers.spin(message(), []);
assert.deepEqual(issued[0].driveDirect, { left: 20, right: -20 });
});
test('spin is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.spin(msg, []);
assert.match(msg.replies[0].content, /need control/);
assert.equal(issued.length, 0);
});
test('boo speaks a canned taunt rather than any caller supplied text', async () => {
const { handlers, issued } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.equal(issued.length, 1);
assert.equal(issued[0].type, 'tts');
// The spoken text must not contain anything the caller typed.
assert.doesNotMatch(issued[0].tts.text, /bob/i);
assert.ok(issued[0].tts.text.length > 0);
});
test('boo is refused when the target is not driving anything', async () => {
const { handlers, issued } = createHarness({ activeDrivers: {} });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /not driving anything/);
assert.equal(issued.length, 0);
});
test('boo is refused when the target is not online at all', async () => {
const { handlers, issued } = createHarness({ sockets: [ALICE] });
const msg = message();
await handlers.boo(msg, ['nobody-here']);
assert.match(msg.replies[0].content, /not here to be booed/);
assert.equal(issued.length, 0);
});
test('boo is refused on a rover that cannot speak', async () => {
const { handlers, issued } = createHarness({ ttsEnabled: false, activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /cannot speak/);
assert.equal(issued.length, 0);
});
test('disco is unavailable when the Home Assistant feature is off', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: false,
homeAssistantService: {
getLightPolicyState: () => ({}),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /unavailable/);
assert.equal(calls.length, 0);
});
test('disco obeys the room light lock', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: true, lockState: 'on' }),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /locked/);
assert.equal(calls.length, 0);
});
test('disco strobes while unlocked and restores the lights on when it ends', async (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] });
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: false }),
setAllControllableEntitiesState: (state) => {
calls.push(state);
return Promise.resolve();
},
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /Disco/);
t.mock.timers.tick(3000);
assert.ok(calls.length >= 2, `expected several ticks, saw ${calls.length}`);
assert.ok(calls.includes('on') && calls.includes('off'));
// Past the end of the window the lights must be put back on and left alone.
t.mock.timers.tick(20 * 1000);
assert.equal(calls[calls.length - 1], 'on');
const settled = calls.length;
t.mock.timers.tick(20 * 1000);
assert.equal(calls.length, settled);
});
test('vibecheck reports the battery and never issues a command', async () => {
const { handlers, issued } = createHarness();
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /Roomba One/);
assert.match(msg.replies[0].content, /Battery 74%/);
assert.equal(issued.length, 0);
});
test('vibecheck leads with the real problem when the battery is urgent', async () => {
const { handlers, record } = createHarness();
record.batteryState = { percentDisplay: 4, warnActive: true, urgentActive: true };
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /dying/);
});
test('vibecheck reports an offline rover as offline', async () => {
const { handlers, record } = createHarness();
record.ws = null;
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /offline/);
});
test('describeBattery falls back through the available fields', () => {
assert.equal(describeBattery({ percentDisplay: 55.4 }), '55%');
assert.equal(describeBattery({ percent: 0.42 }), '42%');
assert.equal(describeBattery({}), 'unknown');
assert.equal(describeBattery(null), 'unknown');
});
@@ -1,118 +0,0 @@
// Operator Fun Stats Commands
// Purpose: Implements the fun commands that read or extend persistent counters (bonkboard, pet, snitch).
// Scope: Reads the roster and the fun stats store; issues no rover commands.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const { PLAIN_MENTIONS, actorLabel, buildActorKey, createRoverResolver } = require('./funHelpers');
const PET_COOLDOWN_MS = 10 * 1000;
const READ_COOLDOWN_MS = 5 * 1000;
const LEADERBOARD_SIZE = 10;
function formatLeaderboard(title, rows, counter) {
const ranked = rows
.filter((row) => Number(row[counter]) > 0)
.sort((left, right) => Number(right[counter]) - Number(left[counter]))
.slice(0, LEADERBOARD_SIZE);
if (!ranked.length) return null;
const lines = ranked.map((row, index) => `${index + 1}. ${row.label || 'unknown'}${row[counter]}`);
return [`**${title}**`, ...lines].join('\n');
}
function createFunStatsCommands({
io,
rovers,
getNickname,
getActiveDrivers,
getActorSocket,
roverManager,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
async function handleBonkboard(message) {
const gated = gate(message, 'bonkboard', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rows = funStatsService.listActorStats();
const sections = [
formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'),
formatLeaderboard('Most bonks taken', rows, 'bonksTaken'),
formatLeaderboard('Most hugs given', rows, 'hugsGiven'),
].filter(Boolean);
if (!sections.length) {
return reply(message, `Nobody has been bonked yet. Fix that with \`${commandPrefix} bonk <user>\`.`);
}
return reply(message, sections.join('\n\n').slice(0, 1900));
}
async function handlePet(message, tokens = []) {
const gated = gate(message, 'pet', PET_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'pet');
if (rover.error) return reply(message, rover.error);
const pets = funStatsService.bumpRoverPets(rover.id, 1);
return reply(message, `🤖 ${gated.label} pets ${rover.name}. It has now been petted ${pets} time${pets === 1 ? '' : 's'}.`);
}
/*
Reads the same active-driver map the turn system uses, so it reports real
control rather than who merely has the page open. Rovers with nobody driving
are listed too an empty fleet is exactly what a snitch should report.
*/
async function handleSnitch(message) {
const gated = gate(message, 'snitch', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const drivers = getActiveDrivers?.() || {};
const sockets = io?.sockets?.sockets;
const lines = [];
rovers.forEach((record, roverId) => {
const id = String(roverId);
const name = record?.meta?.name || id;
const socketId = drivers[id];
const socket = socketId && sockets?.get ? sockets.get(socketId) : null;
const nickname = socket ? getNickname?.(socket) : null;
if (nickname) {
lines.push(`${name}${nickname}`);
} else if (socketId) {
lines.push(`${name} — someone who will not say their name`);
} else {
lines.push(`${name} — nobody`);
}
});
if (!lines.length) return reply(message, 'No rovers are online to snitch about.');
return reply(message, ['🕵️ Currently driving:', ...lines].join('\n').slice(0, 1900));
}
return {
bonkboard: handleBonkboard,
pet: handlePet,
snitch: handleSnitch,
};
}
module.exports = { createFunStatsCommands, formatLeaderboard };

Some files were not shown because too many files have changed in this diff Show More