mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
115
Commits
wiifit
...
17b1404157
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b1404157 | ||
|
|
b199f45eb2 | ||
|
|
0f08fb3f0d | ||
|
|
0f5a33c1de | ||
|
|
8e96c3cdae | ||
|
|
ba5c1c5d25 | ||
|
|
6a914faffb | ||
|
|
3b99590b3b | ||
|
|
5acbf6e0bf | ||
|
|
3ec45de4b4 | ||
|
|
3b7b2ac21e | ||
|
|
02a32e2524 | ||
|
|
eb1fab50e4 | ||
|
|
f85e258c29 | ||
|
|
72b8db8a31 | ||
|
|
fb31ff52bd | ||
|
|
99d2a7689f | ||
|
|
124369dfd7 | ||
|
|
4e24b5437d | ||
|
|
a3c13f3dd3 | ||
|
|
b0389b5ddc | ||
|
|
9ca039229a | ||
|
|
8895ed6bd8 | ||
|
|
6ca7cc0cf0 | ||
|
|
d3fd3946e6 | ||
|
|
038ae0f45a | ||
|
|
3859806fca | ||
|
|
f654394636 | ||
|
|
c8836f7d65 | ||
|
|
f77a969098 | ||
|
|
5ab62c3633 | ||
|
|
fab0673d9e | ||
|
|
a5ec1dcb2d | ||
|
|
6ff60f7d0e | ||
|
|
ea16e2c67a | ||
|
|
9615423e06 | ||
|
|
93232e2a54 | ||
|
|
271197f33c | ||
|
|
8e7d31dcdd | ||
|
|
4f87a0eec2 | ||
|
|
cc2e85d174 | ||
|
|
737760ff56 | ||
|
|
0f0f82e5f6 | ||
|
|
3807b8bb13 | ||
|
|
9f2f819bbd | ||
|
|
60329e1036 | ||
|
|
4430517af5 | ||
|
|
b24d453ad1 | ||
|
|
b5e8d775a2 | ||
|
|
e5830533ba | ||
|
|
dfd674a447 | ||
|
|
bd65f93756 | ||
|
|
0083c887f4 | ||
|
|
70f71b2d1e | ||
|
|
c8742dbbd6 | ||
|
|
6a6dec5540 | ||
|
|
6c69c583c5 | ||
|
|
9702cf0f82 | ||
|
|
a55257dd51 | ||
|
|
8d1761afe2 | ||
|
|
c7c52eb39c | ||
|
|
28fcbad902 | ||
|
|
208b89fd7f | ||
|
|
15a60a58e6 | ||
|
|
3ebd1c7f9c | ||
|
|
7fdcb53041 | ||
|
|
a2dde4fd3d | ||
|
|
8c5d98bed6 | ||
|
|
1aecab66e7 | ||
|
|
ef18ef89e0 | ||
|
|
46bbe5c531 | ||
|
|
dc8267073b | ||
|
|
eb0db3508f | ||
|
|
6aee49bfdb | ||
|
|
a9428d3d72 | ||
|
|
30e961d727 | ||
|
|
a2fbbc100e | ||
|
|
42a7cefeba | ||
|
|
7272c8b4fa | ||
|
|
0e1ad8c6a0 | ||
|
|
09c1257578 | ||
|
|
f3b349bb4a | ||
|
|
81f52c29d8 | ||
|
|
d000b8f4f8 | ||
|
|
0f9bed9c99 | ||
|
|
8642fbac3c | ||
|
|
3a26b4871a | ||
|
|
b26daed93f | ||
|
|
fb9565faf9 | ||
|
|
358aa0b1d6 | ||
|
|
0ecee03f64 | ||
|
|
90d4f778a5 | ||
|
|
b261a4bfa2 | ||
|
|
dd1fd1e167 | ||
|
|
eba4b1dc1d | ||
|
|
cacd125fcb | ||
|
|
8a4162683f | ||
|
|
387a5f47d7 | ||
|
|
5b6947d92c | ||
|
|
cd8f8816c9 | ||
|
|
b86eec88f8 | ||
|
|
512adfc1e0 | ||
|
|
fe33f27bd0 | ||
|
|
afe8ffc62e | ||
|
|
4e6e9e3021 | ||
|
|
f9461433af | ||
|
|
b7d421c489 | ||
|
|
c6b2843150 | ||
|
|
b451849c02 | ||
|
|
955f6f213d | ||
|
|
c9842d7ba5 | ||
|
|
d7fb15d891 | ||
|
|
1cd0e05b4a | ||
|
|
7927768731 | ||
|
|
d9cb0c76b6 |
+5
-1
@@ -21,12 +21,13 @@ 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
|
||||
webui/src/config/analytics.jsx
|
||||
webui/src/config/driverAnalytics.json
|
||||
webui/src/config/analytics.html
|
||||
server/data/analytics.html
|
||||
plans/barcodegames.txt
|
||||
.gitignore
|
||||
server/data/identity.sqlite
|
||||
@@ -34,3 +35,6 @@ server/data/barcode-games.json
|
||||
server/data/identity.sqlite-shm
|
||||
server/data/identity.sqlite-wal
|
||||
server/src/services/balanceBoardService/native/balance_board_worker
|
||||
server/data/fleet-reports.sqlite
|
||||
server/data/fleet-reports.sqlite-shm
|
||||
server/data/fleet-reports.sqlite-wal
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,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 |
@@ -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
@@ -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,117 @@
|
||||
# Simple spectator bot
|
||||
|
||||
A spectator bot connects to the rover server with Socket.IO. It can receive the current session, read chat, and send messages that are visually tagged as bot messages.
|
||||
|
||||
## Install
|
||||
|
||||
Create a small Node.js project and install the Socket.IO client:
|
||||
|
||||
```bash
|
||||
npm install socket.io-client
|
||||
```
|
||||
|
||||
## Example bot
|
||||
|
||||
Create `bot.js`:
|
||||
|
||||
```js
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// Replace this with the public URL of the MultiRoombaRover server.
|
||||
const socket = io('https://your-rover-server.example', {
|
||||
// Match the transports supported by the server while retaining polling as a
|
||||
// fallback for networks or proxies that do not allow WebSocket connections.
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// Socket.IO acknowledgements use callbacks. This small wrapper turns them into
|
||||
// promises so setup failures and rejected chat messages are easy to handle.
|
||||
function emitWithAck(event, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.emit(event, payload, (response = {}) => {
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('connect', async () => {
|
||||
console.log('Connected:', socket.id);
|
||||
|
||||
try {
|
||||
// Set the name that will appear beside this connection and its messages.
|
||||
await emitWithAck('nickname:set', {
|
||||
nickname: 'My spectator bot',
|
||||
});
|
||||
|
||||
// Ask the server to make this passive connection a spectator. Performing
|
||||
// this after every connection also restores the role after a reconnect.
|
||||
await emitWithAck('session:setRole', {
|
||||
role: 'spectator',
|
||||
});
|
||||
|
||||
console.log('Connected as a spectator');
|
||||
} catch (error) {
|
||||
console.error('Spectator setup failed:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Each session:sync event is a complete current session snapshot. Replace any
|
||||
// previously stored session with this object instead of merging snapshots.
|
||||
socket.on('session:sync', (session) => {
|
||||
console.log('Session:', session);
|
||||
});
|
||||
|
||||
// chat:init contains the recent chat history available when the bot connects.
|
||||
socket.on('chat:init', (messages) => {
|
||||
console.log('Recent chat:', messages);
|
||||
});
|
||||
|
||||
// chat:message fires whenever a new message is broadcast, including messages
|
||||
// sent by this bot itself.
|
||||
socket.on('chat:message', (message) => {
|
||||
console.log(`${message.nickname || 'Unknown'}: ${message.text}`);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('Disconnected:', reason);
|
||||
});
|
||||
|
||||
// Setting bot to true adds the normal bot tag to the displayed chat message.
|
||||
// It does not grant the connection any additional permissions.
|
||||
function sendBotMessage(text) {
|
||||
return emitWithAck('chat:send', {
|
||||
text,
|
||||
bot: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Send one example message after the connection has had time to finish setup.
|
||||
// A real bot would call sendBotMessage from its own message-handling logic.
|
||||
setTimeout(() => {
|
||||
sendBotMessage('Hello from my spectator bot!').catch((error) => {
|
||||
console.error('Message failed:', error.message);
|
||||
});
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
node bot.js
|
||||
```
|
||||
|
||||
## Events used
|
||||
|
||||
- `nickname:set` sets the bot's visible nickname.
|
||||
- `session:setRole` changes the connection to a spectator.
|
||||
- `session:sync` provides the latest complete session state.
|
||||
- `chat:init` provides recent chat history after connecting.
|
||||
- `chat:message` provides new chat messages.
|
||||
- `chat:send` sends a chat message. Include `bot: true` to give it the bot tag.
|
||||
|
||||
The server can reject spectator access or a chat message. Always check the acknowledgement callback, as the example does, so those errors are not silently ignored.
|
||||
@@ -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
|
||||
+76
@@ -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);
|
||||
}
|
||||
+11
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The
|
||||
# forward URL is data produced by roverd, and treating it as shell code would
|
||||
# break on normal SRT query-string characters such as '&'.
|
||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The forward URL is
|
||||
# data produced by roverd and must never be interpreted as executable shell code.
|
||||
load_env_file() {
|
||||
local content=""
|
||||
|
||||
@@ -95,6 +94,9 @@ run_pipeline() {
|
||||
-flags low_delay
|
||||
-analyzeduration 200k
|
||||
-probesize 32k
|
||||
# The forwarded-audio URL is RTSP. Pinning TCP avoids ffmpeg negotiating the
|
||||
# separate unreliable RTP/UDP transport that the server intentionally disables.
|
||||
-rtsp_transport tcp
|
||||
-i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
|
||||
-vn
|
||||
)
|
||||
|
||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs
|
||||
# contain characters such as '&' and '#!', so sourcing this file would treat a
|
||||
# data file as code and can split a valid URL into shell control operators.
|
||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. URLs are data;
|
||||
# sourcing this file would unnecessarily treat server-provided values as shell code.
|
||||
load_env_file() {
|
||||
local content=""
|
||||
|
||||
@@ -88,6 +87,7 @@ else
|
||||
fi
|
||||
|
||||
run_pipeline() {
|
||||
local -a pipeline_statuses=()
|
||||
local ffmpeg_args=(
|
||||
-hide_banner
|
||||
-loglevel warning
|
||||
@@ -123,13 +123,14 @@ run_pipeline() {
|
||||
-frame_duration 20
|
||||
-compression_level 0
|
||||
|
||||
# Mirror the video publisher's MPEG-TS low-latency settings. Without
|
||||
# these, ffmpeg is allowed to hold packets for mux timing, which is
|
||||
# exactly the wrong tradeoff for live rover feedback.
|
||||
# RTSP carries the existing Opus stream directly, avoiding MediaMTX's costly
|
||||
# MPEG-TS demux without changing microphone capture or encoding quality. TCP is
|
||||
# required for the same reliable local-network behavior as the video publisher.
|
||||
-flush_packets 1
|
||||
-muxdelay 0
|
||||
-muxpreload 0
|
||||
-f mpegts
|
||||
-f rtsp
|
||||
-rtsp_transport tcp
|
||||
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
|
||||
)
|
||||
|
||||
@@ -146,6 +147,17 @@ run_pipeline() {
|
||||
# latency compared with the old 65,536-byte buffer.
|
||||
arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \
|
||||
| "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}"
|
||||
pipeline_statuses=("${PIPESTATUS[@]}")
|
||||
|
||||
# PIPESTATUS belongs to the pipeline that just finished and is replaced by the next shell
|
||||
# command. Capture it immediately, then return the publisher failure first because that is
|
||||
# normally the reason arecord receives a secondary broken pipe.
|
||||
LAST_ARECORD_STATUS="${pipeline_statuses[0]:-unknown}"
|
||||
LAST_FFMPEG_STATUS="${pipeline_statuses[1]:-unknown}"
|
||||
if [[ "${LAST_FFMPEG_STATUS}" != "0" ]]; then
|
||||
return "${LAST_FFMPEG_STATUS}"
|
||||
fi
|
||||
return "${LAST_ARECORD_STATUS}"
|
||||
}
|
||||
|
||||
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||
@@ -154,6 +166,6 @@ while true; do
|
||||
if run_pipeline; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
|
||||
echo "Audio-only publisher exited arecord=${LAST_ARECORD_STATUS:-unknown} ffmpeg=${LAST_FFMPEG_STATUS:-unknown}, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
done
|
||||
|
||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load roverd's generated media.env as data instead of sourcing it as shell.
|
||||
# The SRT publish URL contains normal query-string characters like '&' and '#!',
|
||||
# so evaluating the file would be both fragile and unnecessary.
|
||||
# Load roverd's generated media.env as data instead of sourcing it as shell. URLs are
|
||||
# configuration data, so evaluating the file would be both fragile and unnecessary.
|
||||
load_env_file() {
|
||||
local content=""
|
||||
|
||||
@@ -103,6 +102,8 @@ if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
|
||||
fi
|
||||
|
||||
run_pipeline() {
|
||||
# Keep laptop rovers on the same transport contract as Pi camera rovers. This changes
|
||||
# only the encoded stream's carrier; V4L2 capture and H264 encoding remain untouched.
|
||||
"${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
@@ -130,7 +131,8 @@ run_pipeline() {
|
||||
-flush_packets 1 \
|
||||
-muxdelay 0 \
|
||||
-muxpreload 0 \
|
||||
-f mpegts \
|
||||
-f rtsp \
|
||||
-rtsp_transport tcp \
|
||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||
}
|
||||
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# These publishers contain hardware-facing infinite retry loops, so executing them in a unit
|
||||
# test would require unsafe process-group traps and fake camera/ALSA devices. Pin the small
|
||||
# transport boundary directly instead: every publisher must request RTSP/TCP and none may
|
||||
# reintroduce the high-latency MPEG-TS muxer.
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
assert_rtsp_tcp() {
|
||||
local file="$1"
|
||||
if ! grep -q -- '-f rtsp' "$file"; then
|
||||
echo "Missing RTSP muxer in $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-rtsp_transport tcp' "$file"; then
|
||||
echo "Missing RTSP/TCP pin in $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-f mpegts' "$file"; then
|
||||
echo "Unexpected MPEG-TS muxer in $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_rtsp_tcp "$SCRIPT_DIR/video-publisher.sh"
|
||||
assert_rtsp_tcp "$SCRIPT_DIR/debian-laptop-video-publisher.sh"
|
||||
assert_rtsp_tcp "$SCRIPT_DIR/audio-only-publisher.sh"
|
||||
|
||||
# The speaker path reads rather than publishes, so it has no output muxer. It must still pin
|
||||
# RTSP/TCP before its input URL to match the server's TCP-only listener.
|
||||
if ! grep -q -- '-rtsp_transport tcp' "$SCRIPT_DIR/audio-forward-listener.sh"; then
|
||||
echo "Missing RTSP/TCP input pin in audio-forward-listener.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Media publisher transport checks passed"
|
||||
@@ -110,6 +110,10 @@ else
|
||||
fi
|
||||
|
||||
run_pipeline() {
|
||||
# MPEG-TS added most of the former rover-to-browser latency inside MediaMTX's
|
||||
# demuxer. RTSP carries the same encoded H264 without changing the camera or codec.
|
||||
# TCP is explicit because plain RTSP/RTP over UDP has no retransmission and proved
|
||||
# unreliable even though MediaMTX still reported the incomplete stream as ready.
|
||||
"${LIBCAMERA_BIN_PATH}" \
|
||||
--inline \
|
||||
--timeout 0 \
|
||||
@@ -142,7 +146,8 @@ run_pipeline() {
|
||||
-flush_packets 1 \
|
||||
-muxdelay 0 \
|
||||
-muxpreload 0 \
|
||||
-f mpegts \
|
||||
-f rtsp \
|
||||
-rtsp_transport tcp \
|
||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ write_media_env_placeholder() {
|
||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||
ROVERD_VIDEO_ENABLE=1
|
||||
ROVERD_VIDEO_PUBLISHER=pi-libcamera
|
||||
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
|
||||
ROVERD_VIDEO_DEVICE=
|
||||
ROVERD_VIDEO_INPUT_FORMAT=
|
||||
ROVERD_VIDEO_WIDTH=640
|
||||
@@ -23,13 +23,13 @@ ROVERD_VIDEO_BITRATE=2000000
|
||||
ROVERD_VIDEO_INVERT=1
|
||||
ROVERD_VIDEO_SENSOR_MODE=1296:972
|
||||
ROVERD_AUDIO_CAPTURE_ENABLE=0
|
||||
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
|
||||
ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0
|
||||
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
||||
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
||||
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
||||
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
||||
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
|
||||
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
|
||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
||||
@@ -43,7 +43,7 @@ ENV
|
||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||
ROVERD_VIDEO_ENABLE=1
|
||||
ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2
|
||||
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
|
||||
ROVERD_VIDEO_DEVICE=/dev/video0
|
||||
ROVERD_VIDEO_INPUT_FORMAT=mjpeg
|
||||
ROVERD_VIDEO_WIDTH=640
|
||||
@@ -53,13 +53,13 @@ ROVERD_VIDEO_BITRATE=2000000
|
||||
ROVERD_VIDEO_INVERT=0
|
||||
ROVERD_VIDEO_SENSOR_MODE=
|
||||
ROVERD_AUDIO_CAPTURE_ENABLE=1
|
||||
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
|
||||
ROVERD_AUDIO_CAPTURE_DEVICE=default
|
||||
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
||||
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
||||
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
||||
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
||||
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
|
||||
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
|
||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
+57
-50
@@ -3,9 +3,11 @@ package roverd
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -77,10 +79,10 @@ type HornConfig struct {
|
||||
}
|
||||
|
||||
type MediaConfig struct {
|
||||
// PublishPort is shared by the derived video, microphone, and forwarded-audio
|
||||
// SRT URLs. Keeping it at this level prevents each nested block from needing
|
||||
// RTSPPort is shared by the derived video, microphone, and forwarded-audio
|
||||
// RTSP URLs. Keeping it at this level prevents each nested block from needing
|
||||
// to repeat the same server port when the common MediaMTX listener is used.
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
RTSPPort int `yaml:"rtspPort" json:"-"`
|
||||
Manage bool `yaml:"manage" json:"manage"`
|
||||
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
|
||||
HealthInterval Duration `yaml:"healthInterval" json:"-"`
|
||||
@@ -93,10 +95,12 @@ type VideoMediaConfig struct {
|
||||
// Publisher selects the installed publisher script/pipeline family. The
|
||||
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
|
||||
// without changing the server-facing media shape again.
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
|
||||
// PublishURL is derived during validation. It remains in rover metadata for server-side
|
||||
// consumers, but is not a second hand-written endpoint in /etc/roverd.yaml.
|
||||
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
InputFormat string `yaml:"inputFormat" json:"-"`
|
||||
Width int `yaml:"width" json:"-"`
|
||||
@@ -111,9 +115,10 @@ type AudioCaptureConfig struct {
|
||||
// AudioCapture describes the rover microphone stream that browsers can
|
||||
// subscribe to as "<rover>-audio". A disabled capture block still has
|
||||
// normalized defaults so enabling it only requires flipping enabled: true.
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
// PublishURL follows the same derived-only contract as the video path.
|
||||
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||
Channels int `yaml:"channels" json:"-"`
|
||||
@@ -125,9 +130,10 @@ type AudioPlaybackConfig struct {
|
||||
// MediaMTX for playback on the rover speaker. The URL is a request/read URL
|
||||
// for the rover listener, while the server converts it to publish mode when
|
||||
// it needs to inject audio.
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Service string `yaml:"service" json:"service,omitempty"`
|
||||
// ForwardURL is derived because the server and rover must agree on the exact -fwd path.
|
||||
ForwardURL string `yaml:"-" json:"forwardUrl,omitempty"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
Normalize bool `yaml:"normalize" json:"-"`
|
||||
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
|
||||
@@ -230,7 +236,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||
},
|
||||
},
|
||||
Media: MediaConfig{
|
||||
PublishPort: 9000,
|
||||
RTSPPort: 8554,
|
||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||
Video: VideoMediaConfig{
|
||||
Enabled: true,
|
||||
@@ -349,8 +355,8 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if cfg.BRC.GPIOChip == "" {
|
||||
cfg.BRC.GPIOChip = "gpiochip0"
|
||||
}
|
||||
if cfg.Media.PublishPort <= 0 {
|
||||
cfg.Media.PublishPort = 9000
|
||||
if cfg.Media.RTSPPort <= 0 {
|
||||
cfg.Media.RTSPPort = 8554
|
||||
}
|
||||
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
|
||||
return nil, fmt.Errorf("media: %w", err)
|
||||
@@ -432,25 +438,25 @@ func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) e
|
||||
file is written. This keeps the Pi behavior stable while making laptop
|
||||
and future publisher variants explicit configuration choices.
|
||||
*/
|
||||
if cfg.PublishPort <= 0 {
|
||||
cfg.PublishPort = 9000
|
||||
if cfg.RTSPPort <= 0 {
|
||||
cfg.RTSPPort = 8554
|
||||
}
|
||||
if cfg.HealthInterval.Duration <= 0 {
|
||||
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||
}
|
||||
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.RTSPPort); err != nil {
|
||||
return fmt.Errorf("video: %w", err)
|
||||
}
|
||||
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.RTSPPort); err != nil {
|
||||
return fmt.Errorf("audioCapture: %w", err)
|
||||
}
|
||||
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.RTSPPort); err != nil {
|
||||
return fmt.Errorf("audioPlayback: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error {
|
||||
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, rtspPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "video-publisher.service"
|
||||
}
|
||||
@@ -476,17 +482,19 @@ func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName
|
||||
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
|
||||
cfg.SensorMode = "1296:972"
|
||||
}
|
||||
if cfg.PublishURL == "" {
|
||||
derived, err := derivePublishURL(serverURL, roverName, publishPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.PublishURL = derived
|
||||
/*
|
||||
Always derive this endpoint. Older rover configs can contain an explicit SRT publishUrl;
|
||||
honoring it after a binary update would silently leave that rover on the old transport.
|
||||
*/
|
||||
derived, err := derivePublishURL(serverURL, roverName, rtspPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.PublishURL = derived
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error {
|
||||
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, rtspPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "audio-only-publisher.service"
|
||||
}
|
||||
@@ -502,17 +510,15 @@ func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, rover
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 510000
|
||||
}
|
||||
if cfg.PublishURL == "" {
|
||||
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.PublishURL = derived
|
||||
derived, err := derivePublishURL(serverURL, roverName+"-audio", rtspPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.PublishURL = derived
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error {
|
||||
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, rtspPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "audio-forward-listener.service"
|
||||
}
|
||||
@@ -522,13 +528,11 @@ func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, rov
|
||||
if cfg.NormalizeFilter == "" {
|
||||
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
|
||||
}
|
||||
if cfg.ForwardURL == "" {
|
||||
derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive forwardUrl: %w", err)
|
||||
}
|
||||
cfg.ForwardURL = derived
|
||||
derived, err := deriveReadURL(serverURL, roverName+"-fwd", rtspPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive forwardUrl: %w", err)
|
||||
}
|
||||
cfg.ForwardURL = derived
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -577,20 +581,17 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
|
||||
}
|
||||
|
||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||
return deriveSRTURL(serverURL, streamName, port, "publish")
|
||||
return deriveRTSPURL(serverURL, streamName, port)
|
||||
}
|
||||
|
||||
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
||||
return deriveSRTURL(serverURL, streamName, port, "request")
|
||||
return deriveRTSPURL(serverURL, streamName, port)
|
||||
}
|
||||
|
||||
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
|
||||
func deriveRTSPURL(serverURL, streamName string, port int) (string, error) {
|
||||
if streamName == "" {
|
||||
return "", errors.New("missing stream name for publishUrl")
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "publish"
|
||||
}
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -600,10 +601,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
|
||||
return "", errors.New("serverUrl missing host")
|
||||
}
|
||||
if port <= 0 {
|
||||
port = 9000
|
||||
port = 8554
|
||||
}
|
||||
/*
|
||||
JoinHostPort handles both ordinary hostnames and bracketed IPv6 addresses. The rover name
|
||||
is a MediaMTX path, so it is escaped independently instead of interpolated into the host.
|
||||
RTSP distinguishes publishing from reading through protocol methods, which is why both
|
||||
directions intentionally use the same URL shape.
|
||||
*/
|
||||
escaped := url.PathEscape(streamName)
|
||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
|
||||
return fmt.Sprintf("rtsp://%s/%s", net.JoinHostPort(host, strconv.Itoa(port)), escaped), nil
|
||||
}
|
||||
|
||||
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+93
-6
@@ -3,6 +3,7 @@ package roverd
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
hostStatsInterval = 5 * time.Second
|
||||
hostStatsInterval = 1 * time.Second
|
||||
rootFilesystem = "/"
|
||||
)
|
||||
|
||||
@@ -63,7 +64,24 @@ type WiFiStats struct {
|
||||
TXBytes *uint64 `json:"txBytes,omitempty"`
|
||||
RXPackets *uint64 `json:"rxPackets,omitempty"`
|
||||
TXPackets *uint64 `json:"txPackets,omitempty"`
|
||||
DownloadMbps *float64 `json:"downloadMbps,omitempty"`
|
||||
UploadMbps *float64 `json:"uploadMbps,omitempty"`
|
||||
InactiveMs *int `json:"inactiveMs,omitempty"`
|
||||
|
||||
// networkSampledAt records the instant associated with the kernel byte
|
||||
// counters. Keeping it out of JSON lets the websocket loop calculate rates
|
||||
// with monotonic Go timestamps without expanding the browser contract with
|
||||
// an implementation-only value.
|
||||
networkSampledAt time.Time
|
||||
}
|
||||
|
||||
// networkRateSample is scoped to one rover websocket connection. A new
|
||||
// connection intentionally starts a new baseline so counters from an old boot
|
||||
// or network interface lifetime can never create an artificial traffic spike.
|
||||
type networkRateSample struct {
|
||||
rxBytes uint64
|
||||
txBytes uint64
|
||||
sampledAt time.Time
|
||||
}
|
||||
|
||||
// CollectHostStats gathers every source independently so one missing kernel
|
||||
@@ -370,12 +388,81 @@ func collectWiFiStats(ctx context.Context) (*WiFiStats, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The interface is used only to ask iw about the active connection. It is
|
||||
// not copied into WiFiStats because the UI does not need to expose it.
|
||||
if err := enrichWiFiWithIW(ctx, iface, stats); err != nil {
|
||||
return stats, err
|
||||
// The interface is used only for local collection. It is not copied into
|
||||
// WiFiStats because the UI does not need to expose Linux device names.
|
||||
iwErr := enrichWiFiWithIW(ctx, iface, stats)
|
||||
|
||||
// Read the kernel counters after iw because iw also provides cumulative
|
||||
// station counters. The kernel interface values deliberately win: they are
|
||||
// the host-traffic source used for both the cumulative display and Mbps math.
|
||||
// Link capacity still comes independently from iw's bitrate fields.
|
||||
counterErr := enrichWiFiWithNetworkCounters(iface, stats)
|
||||
return stats, errors.Join(counterErr, iwErr)
|
||||
}
|
||||
|
||||
func enrichWiFiWithNetworkCounters(iface string, stats *WiFiStats) error {
|
||||
basePath := "/sys/class/net/" + iface + "/statistics/"
|
||||
rxBytes, err := readUintFile(basePath + "rx_bytes")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s receive bytes: %w", iface, err)
|
||||
}
|
||||
return stats, nil
|
||||
txBytes, err := readUintFile(basePath + "tx_bytes")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s transmit bytes: %w", iface, err)
|
||||
}
|
||||
|
||||
stats.RXBytes = &rxBytes
|
||||
stats.TXBytes = &txBytes
|
||||
// Capture the timestamp immediately beside the counter reads so unrelated
|
||||
// host-stat collection latency cannot distort the elapsed-time divisor.
|
||||
stats.networkSampledAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func readUintFile(path string) (uint64, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
|
||||
}
|
||||
|
||||
func applyNetworkThroughput(stats *WiFiStats, previous *networkRateSample) *networkRateSample {
|
||||
if stats == nil || stats.RXBytes == nil || stats.TXBytes == nil || stats.networkSampledAt.IsZero() {
|
||||
// Do not discard the last valid baseline during a temporary read failure.
|
||||
// The next successful calculation then covers the full elapsed interval and
|
||||
// remains an accurate average for all traffic transferred during the gap.
|
||||
return previous
|
||||
}
|
||||
|
||||
current := &networkRateSample{
|
||||
rxBytes: *stats.RXBytes,
|
||||
txBytes: *stats.TXBytes,
|
||||
sampledAt: stats.networkSampledAt,
|
||||
}
|
||||
if previous == nil {
|
||||
return current
|
||||
}
|
||||
|
||||
elapsed := current.sampledAt.Sub(previous.sampledAt).Seconds()
|
||||
// Linux counters can return to zero after an interface reset. Re-baselining
|
||||
// on any decrease prevents unsigned underflow from becoming a huge false
|
||||
// throughput spike in the host-stat card.
|
||||
if elapsed <= 0 || current.rxBytes < previous.rxBytes || current.txBytes < previous.txBytes {
|
||||
return current
|
||||
}
|
||||
|
||||
downloadMbps := bytesToMbps(current.rxBytes-previous.rxBytes, elapsed)
|
||||
uploadMbps := bytesToMbps(current.txBytes-previous.txBytes, elapsed)
|
||||
stats.DownloadMbps = &downloadMbps
|
||||
stats.UploadMbps = &uploadMbps
|
||||
return current
|
||||
}
|
||||
|
||||
func bytesToMbps(byteDelta uint64, elapsedSeconds float64) float64 {
|
||||
// Mbps uses decimal megabits, matching network equipment and link-rate
|
||||
// conventions: eight bits per byte and 1,000,000 bits per megabit.
|
||||
return roundOneDecimal((float64(byteDelta) * 8) / elapsedSeconds / 1_000_000)
|
||||
}
|
||||
|
||||
func readWirelessStats() (string, *WiFiStats, error) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyNetworkThroughputCalculatesMbpsFromActualElapsedTime(t *testing.T) {
|
||||
startedAt := time.Unix(100, 0)
|
||||
previous := &networkRateSample{rxBytes: 1_000, txBytes: 2_000, sampledAt: startedAt}
|
||||
rxBytes := uint64(2_001_000)
|
||||
txBytes := uint64(1_002_000)
|
||||
stats := &WiFiStats{
|
||||
RXBytes: &rxBytes,
|
||||
TXBytes: &txBytes,
|
||||
networkSampledAt: startedAt.Add(2 * time.Second),
|
||||
}
|
||||
|
||||
next := applyNetworkThroughput(stats, previous)
|
||||
|
||||
if stats.DownloadMbps == nil || *stats.DownloadMbps != 8.0 {
|
||||
t.Fatalf("expected 8.0 Mbps download, got %v", stats.DownloadMbps)
|
||||
}
|
||||
if stats.UploadMbps == nil || *stats.UploadMbps != 4.0 {
|
||||
t.Fatalf("expected 4.0 Mbps upload, got %v", stats.UploadMbps)
|
||||
}
|
||||
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
|
||||
t.Fatalf("expected current counters to become the next baseline, got %#v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNetworkThroughputFirstSampleOnlyEstablishesBaseline(t *testing.T) {
|
||||
rxBytes := uint64(100)
|
||||
txBytes := uint64(200)
|
||||
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: time.Unix(100, 0)}
|
||||
|
||||
next := applyNetworkThroughput(stats, nil)
|
||||
|
||||
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||
t.Fatalf("expected no rates for the first sample, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatal("expected the first valid sample to establish a baseline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNetworkThroughputCounterResetEstablishesNewBaseline(t *testing.T) {
|
||||
startedAt := time.Unix(100, 0)
|
||||
previous := &networkRateSample{rxBytes: 10_000, txBytes: 20_000, sampledAt: startedAt}
|
||||
rxBytes := uint64(10)
|
||||
txBytes := uint64(20)
|
||||
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: startedAt.Add(time.Second)}
|
||||
|
||||
next := applyNetworkThroughput(stats, previous)
|
||||
|
||||
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||
t.Fatalf("expected no rates after a counter reset, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||
}
|
||||
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
|
||||
t.Fatalf("expected reset counters to become the new baseline, got %#v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNetworkThroughputInvalidElapsedTimeEstablishesNewBaseline(t *testing.T) {
|
||||
sampledAt := time.Unix(100, 0)
|
||||
previous := &networkRateSample{rxBytes: 100, txBytes: 200, sampledAt: sampledAt}
|
||||
rxBytes := uint64(200)
|
||||
txBytes := uint64(300)
|
||||
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: sampledAt}
|
||||
|
||||
next := applyNetworkThroughput(stats, previous)
|
||||
|
||||
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||
t.Fatalf("expected no rates with zero elapsed time, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||
}
|
||||
if next == nil || next.sampledAt != sampledAt {
|
||||
t.Fatalf("expected invalid timing sample to become the new baseline, got %#v", next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package roverd
|
||||
|
||||
// These tests pin the network-agnostic RTSP contract. A rover provides its server URL and name
|
||||
// once; all three media paths must then resolve to distinct, safely escaped MediaMTX paths.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMediaURLsDeriveFromServerURLAndRoverName(t *testing.T) {
|
||||
cfg := MediaConfig{
|
||||
Video: VideoMediaConfig{Enabled: true},
|
||||
AudioCapture: AudioCaptureConfig{Enabled: true},
|
||||
AudioPlayback: AudioPlaybackConfig{Enabled: true},
|
||||
}
|
||||
if err := validateMediaConfig(&cfg, "ws://control-server.local:8080/rover", "rover one"); err != nil {
|
||||
t.Fatalf("validate media config: %v", err)
|
||||
}
|
||||
|
||||
wants := map[string]string{
|
||||
"video": "rtsp://control-server.local:8554/rover%20one",
|
||||
"mic": "rtsp://control-server.local:8554/rover%20one-audio",
|
||||
"speaker": "rtsp://control-server.local:8554/rover%20one-fwd",
|
||||
}
|
||||
got := map[string]string{
|
||||
"video": cfg.Video.PublishURL,
|
||||
"mic": cfg.AudioCapture.PublishURL,
|
||||
"speaker": cfg.AudioPlayback.ForwardURL,
|
||||
}
|
||||
for name, want := range wants {
|
||||
if got[name] != want {
|
||||
t.Errorf("%s URL: got %q, want %q", name, got[name], want)
|
||||
}
|
||||
}
|
||||
if cfg.RTSPPort != 8554 {
|
||||
t.Fatalf("RTSP port: got %d, want 8554", cfg.RTSPPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitMediaPortAppliesToEveryRTSPPath(t *testing.T) {
|
||||
cfg := MediaConfig{
|
||||
RTSPPort: 10554,
|
||||
Video: VideoMediaConfig{Enabled: true},
|
||||
AudioCapture: AudioCaptureConfig{Enabled: true},
|
||||
AudioPlayback: AudioPlaybackConfig{Enabled: true},
|
||||
}
|
||||
if err := validateMediaConfig(&cfg, "ws://media.example/rover", "r1"); err != nil {
|
||||
t.Fatalf("validate media config: %v", err)
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
|
||||
} {
|
||||
if !strings.Contains(value, ":10554/") {
|
||||
t.Errorf("%s URL did not use configured port: %q", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyExplicitSRTURLsCannotKeepAnUpdatedRoverOnTheOldTransport(t *testing.T) {
|
||||
/*
|
||||
Deployed rover configs can still contain these former fields. Validation must replace
|
||||
them unconditionally so updating roverd is sufficient to move the whole media path.
|
||||
*/
|
||||
cfg := MediaConfig{
|
||||
Video: VideoMediaConfig{Enabled: true, PublishURL: "srt://old/video"},
|
||||
AudioCapture: AudioCaptureConfig{Enabled: true, PublishURL: "srt://old/audio"},
|
||||
AudioPlayback: AudioPlaybackConfig{Enabled: true, ForwardURL: "srt://old/forward"},
|
||||
}
|
||||
if err := validateMediaConfig(&cfg, "ws://new-server.local:8080/rover", "r1"); err != nil {
|
||||
t.Fatalf("validate media config: %v", err)
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
|
||||
} {
|
||||
if !strings.HasPrefix(value, "rtsp://new-server.local:8554/") {
|
||||
t.Errorf("%s retained an old transport URL: %q", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -22,7 +22,8 @@ battery:
|
||||
maxWheelSpeed: 350
|
||||
|
||||
media:
|
||||
publishPort: 9000
|
||||
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
|
||||
rtspPort: 8554
|
||||
manage: true
|
||||
healthUrl: ""
|
||||
healthInterval: 30s
|
||||
|
||||
@@ -17,7 +17,8 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishPort: 9000
|
||||
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
|
||||
rtspPort: 8554
|
||||
manage: true
|
||||
healthUrl: ""
|
||||
healthInterval: 30s
|
||||
@@ -25,7 +26,6 @@ media:
|
||||
enabled: true
|
||||
service: video-publisher.service
|
||||
publisher: pi-libcamera
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
width: 640
|
||||
height: 480
|
||||
fps: 30
|
||||
@@ -36,7 +36,6 @@ media:
|
||||
audioCapture:
|
||||
enabled: false
|
||||
service: audio-only-publisher.service
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
device: hw:0,0
|
||||
sampleRate: 48000
|
||||
channels: 2
|
||||
@@ -44,7 +43,6 @@ media:
|
||||
audioPlayback:
|
||||
enabled: true
|
||||
service: audio-forward-listener.service
|
||||
forwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
device: forward
|
||||
normalize: true
|
||||
cameraServo:
|
||||
|
||||
+112
-12
@@ -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{
|
||||
@@ -531,14 +561,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
||||
}
|
||||
|
||||
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
|
||||
var previousNetworkSample *networkRateSample
|
||||
|
||||
send := func() bool {
|
||||
// Host stats are collected on demand so each outbound message describes
|
||||
// the current Pi state. Collection failures are encoded into the stats
|
||||
// payload, which keeps this telemetry path from closing the rover socket.
|
||||
stats := CollectHostStats(ctx)
|
||||
// Throughput is derived here because this loop owns the ordered, periodic
|
||||
// samples for one connection. CollectHostStats stays independent, while a
|
||||
// reconnect automatically receives a clean counter baseline.
|
||||
previousNetworkSample = applyNetworkThroughput(stats.WiFi, previousNetworkSample)
|
||||
msg := hostStatsMessage{
|
||||
Type: "hostStats",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Stats: CollectHostStats(ctx),
|
||||
Stats: stats,
|
||||
}
|
||||
if err := writeJSON(ctx, conn, msg); err != nil {
|
||||
c.log.Printf("host stats send failed: %v", err)
|
||||
@@ -634,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
|
||||
@@ -647,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)
|
||||
}
|
||||
@@ -661,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() {
|
||||
@@ -672,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
|
||||
@@ -688,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 {
|
||||
@@ -713,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 {
|
||||
@@ -726,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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Rover Audio Forward Listener (SRT -> ALSA)
|
||||
Description=Rover Audio Forward Listener (RTSP/TCP -> ALSA)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Rover Audio Publisher (ALSA -> SRT)
|
||||
Description=Rover Audio Publisher (ALSA -> RTSP/TCP)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Rover Debian Laptop Video Publisher (V4L2 -> SRT)
|
||||
Description=Rover Debian Laptop Video Publisher (V4L2 -> RTSP/TCP)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
[Unit]
|
||||
Description=Multi-Roomba rover control agent
|
||||
After=network-online.target mediamtx.service
|
||||
After=network-online.target
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Rover Video Publisher (libcamera -> SRT)
|
||||
Description=Rover Video Publisher (libcamera -> RTSP/TCP)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
@@ -51,8 +51,15 @@ barcodeGames:
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
# Example: http://192.168.0.86:8889/video
|
||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||
# Example: http://media-server.local:8889/video
|
||||
whepBaseUrl: "http://media-server.local:8889/video"
|
||||
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
|
||||
# candidates. Include every public and LAN address browsers use to reach this server.
|
||||
# The server generates MediaMTX's runtime configuration from this list; never edit a
|
||||
# separate mediamtx.yml for a new installation.
|
||||
additionalHosts:
|
||||
- "rover.example.com"
|
||||
- "media-server.local"
|
||||
|
||||
bandwidthSavings:
|
||||
# Duplicate driver-tab handling for the same browser identity.
|
||||
@@ -60,6 +67,11 @@ bandwidthSavings:
|
||||
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
||||
# notAllowed: every identity is limited to one driver tab
|
||||
multiTabProtection: "verifiedOnly"
|
||||
# Disconnect rover video when its player is outside the viewport or the web
|
||||
# page is in a background browser tab. Rover audio is a separate stream and
|
||||
# remains connected. /mini intentionally keeps its existing always-warm video
|
||||
# behavior regardless of this option.
|
||||
pauseHiddenRoverVideo: false
|
||||
# Video for users who are attached to a source but do not currently own its
|
||||
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
|
||||
# whenever the normal mode/visibility rules allow it.
|
||||
@@ -87,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
|
||||
@@ -227,3 +244,35 @@ socials:
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
|
||||
# Optional trusted HTML card shown at the bottom of the desktop driver page's
|
||||
# left column. Leave html empty (or omit this section) to hide the card. This
|
||||
# content is sent to driver browsers without sanitization, so only place markup
|
||||
# here that is controlled by the server operator.
|
||||
driverAd:
|
||||
title: "Advertisement"
|
||||
html: |
|
||||
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://example.com/ad.png" alt="Advertisement" style="display:block;width:100%;height:auto;">
|
||||
</a>
|
||||
|
||||
# Optional passive fleet telemetry, history, and daily reporting. The collector
|
||||
# observes existing server events and rover sensor frames but never participates
|
||||
# in command, assignment, docking, or safety decisions.
|
||||
fleetReports:
|
||||
enabled: false
|
||||
retention:
|
||||
# Zero retains evidence indefinitely. Set explicit day counts on servers
|
||||
# that prefer bounded storage over complete long-term history.
|
||||
detailedDays: 0
|
||||
minuteSamplesDays: 0
|
||||
battery:
|
||||
enabled: true
|
||||
maximumIntegrationGapSeconds: 5
|
||||
minimumCapacityTestDepthPercent: 60
|
||||
discord:
|
||||
enabled: true
|
||||
sendAt: "08:00"
|
||||
timezone: "America/New_York"
|
||||
privacy:
|
||||
retainChatBodies: true
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
Umami example for the optional provider-neutral rover analytics bridge.
|
||||
|
||||
Copy this file to analytics.html in the same data directory, replace the
|
||||
example URLs and attributes, and restart the server. The server injects the
|
||||
copied file into every web UI entry page; this example filename is not loaded
|
||||
automatically.
|
||||
-->
|
||||
<script defer src="https://analytics.example.com/script.js" data-website-id="replace-with-website-id" data-domains="rover.example.com"></script>
|
||||
<script defer src="https://analytics.example.com/recorder.js" data-website-id="replace-with-website-id" data-domains="rover.example.com" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
|
||||
<script>
|
||||
window.roverAnalytics = {
|
||||
track: function (name, data) {
|
||||
window.umami?.track(name, data);
|
||||
},
|
||||
identify: function (data) {
|
||||
window.umami?.identify(data);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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');
|
||||
@@ -28,6 +31,7 @@ require('./src/services/serverControlService');
|
||||
require('./src/services/videoSessions');
|
||||
require('./src/services/ptzCameraService');
|
||||
require('./src/services/videoAuthService');
|
||||
require('./src/services/mediaMtxService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/roomCameraService');
|
||||
require('./src/services/roverSnapshotService');
|
||||
@@ -49,6 +53,10 @@ require('./src/services/kinectService');
|
||||
require('./src/services/balanceBoardService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
// Fleet reporting starts after the rover and battery services so its passive
|
||||
// subscriptions see fully decoded state without becoming an initialization
|
||||
// dependency of either control path.
|
||||
require('./src/services/fleetReportService');
|
||||
require('./src/services/replayEngineV2');
|
||||
// Replay delivery is a core service. It must subscribe before the optional
|
||||
// Discord feature so web requests always have a local delivery path.
|
||||
|
||||
+40
-47
@@ -8,13 +8,9 @@ NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/downl
|
||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||
NEOLINK_BIN="/usr/local/bin/neolink"
|
||||
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
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"
|
||||
@@ -32,10 +28,11 @@ 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"
|
||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
||||
|
||||
@@ -259,53 +256,52 @@ if ! verify_google_tts_helper; then
|
||||
verify_google_tts_helper
|
||||
fi
|
||||
|
||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
||||
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -f "$MEDIAMTX_CONFIG" ]]; then
|
||||
echo " Preserving existing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||
else
|
||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
||||
fi
|
||||
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||
|
||||
# Validate the new source of truth before disabling a working legacy service. The validator
|
||||
# performs the same build and YAML serialization as server startup without opening listeners
|
||||
# or leaving a process behind.
|
||||
runuser -u "$TARGET_USER" -- env \
|
||||
SERVER_CONFIG="$CONFIG_PATH" \
|
||||
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
|
||||
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
|
||||
|
||||
# MediaMTX used to run as its own systemd service with a hand-maintained config in
|
||||
# /etc/mediamtx. Stop it before multirover starts the new child process, otherwise the two
|
||||
# processes race for every media listener. Both commands are deliberately idempotent so an
|
||||
# already-migrated server and a first-time installation follow the same path.
|
||||
echo " Disabling legacy mediamtx.service"
|
||||
systemctl disable --now mediamtx.service 2>/dev/null || true
|
||||
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"
|
||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=mediaMTX WebRTC Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
Group=$TARGET_USER
|
||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
# 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
|
||||
After=network-online.target mediamtx.service bluetooth.service
|
||||
After=network-online.target bluetooth.service
|
||||
Wants=network-online.target bluetooth.service
|
||||
|
||||
[Service]
|
||||
@@ -314,8 +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
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
@@ -325,20 +321,17 @@ SuccessExitStatus=130 143
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
||||
chmod 644 "$MULTIROVER_SERVICE"
|
||||
|
||||
echo "[5/6] Enabling services..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mediamtx.service
|
||||
systemctl enable --now multirover.service
|
||||
systemctl restart mediamtx.service
|
||||
systemctl restart multirover.service
|
||||
|
||||
echo "[6/6] Done."
|
||||
echo
|
||||
echo "Services installed:"
|
||||
echo " mediamtx.service (WebRTC fan-out)"
|
||||
echo " multirover.service (Node.js control server)"
|
||||
echo " multirover.service (Node.js control server with MediaMTX child)"
|
||||
echo
|
||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
|
||||
logLevel: info
|
||||
|
||||
api: yes
|
||||
apiAddress: 0.0.0.0:9997
|
||||
metrics: yes
|
||||
metricsAddress: 0.0.0.0:9998
|
||||
pprof: no
|
||||
pprofAddress: 127.0.0.1:9999
|
||||
|
||||
rtsp: no
|
||||
rtmp: no
|
||||
hls: no
|
||||
|
||||
webrtc: yes
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcLocalTCPAddress: :8189
|
||||
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
|
||||
webrtcICEServers2:
|
||||
# Google public STUN (world-wide, very commonly used)
|
||||
- url: stun:stun.l.google.com:19302
|
||||
- url: stun:stun1.l.google.com:19302
|
||||
- url: stun:stun2.l.google.com:19302
|
||||
- url: stun:stun3.l.google.com:19302
|
||||
- url: stun:stun4.l.google.com:19302
|
||||
|
||||
# Cloudflare STUN (anycast, global PoPs)
|
||||
- url: stun:stun.cloudflare.com:3478
|
||||
|
||||
srt: yes
|
||||
srtAddress: :9000
|
||||
|
||||
authMethod: http
|
||||
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
||||
authHTTPExclude:
|
||||
- action: api
|
||||
- action: metrics
|
||||
- action: pprof
|
||||
|
||||
paths:
|
||||
all:
|
||||
source: publisher
|
||||
sourceOnDemand: no
|
||||
# Rover Snapshot Writer
|
||||
# Keep rover snapshots continuously updated while a rover video path is live.
|
||||
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
|
||||
runOnReadyRestart: yes
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"home-assistant-js-websocket": "^3.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"kokoro-js": "^1.2.1",
|
||||
"luxon": "^3.7.2",
|
||||
"morgan": "^1.10.0",
|
||||
"obscenity": "^0.4.6",
|
||||
"ollama": "^0.6.3",
|
||||
|
||||
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 |
@@ -4,82 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||
<link rel="apple-touch-icon" href="/bitmap.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<!-- The server renders this manifest so installed shortcuts use the local instance's configured branding. -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
||||
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
||||
|
||||
<script>
|
||||
/*
|
||||
Build-time analytics adapter for the rover UI.
|
||||
|
||||
React only calls window.roverAnalytics.track/identify. Keeping the Umami
|
||||
adapter here means analytics can still be removed, replaced, or configured
|
||||
by changing this injected file instead of rebuilding app logic around a
|
||||
specific analytics provider.
|
||||
*/
|
||||
(function () {
|
||||
var pendingCalls = [];
|
||||
var flushTimer = null;
|
||||
|
||||
function callUmami(method, args) {
|
||||
if (!window.umami || typeof window.umami[method] !== 'function') return false;
|
||||
window.umami[method].apply(window.umami, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flushPendingCalls() {
|
||||
if (!pendingCalls.length) return;
|
||||
if (!window.umami) return;
|
||||
|
||||
pendingCalls = pendingCalls.filter(function (call) {
|
||||
return !callUmami(call.method, call.args);
|
||||
});
|
||||
|
||||
if (!pendingCalls.length && flushTimer) {
|
||||
window.clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(method, args) {
|
||||
if (callUmami(method, args)) return;
|
||||
pendingCalls.push({ method: method, args: args });
|
||||
|
||||
/*
|
||||
The React app may fire route/session events before Umami's deferred
|
||||
script has executed. Queueing preserves those early events while still
|
||||
letting the whole adapter no-op harmlessly if the script is blocked.
|
||||
*/
|
||||
if (!flushTimer) {
|
||||
flushTimer = window.setInterval(flushPendingCalls, 500);
|
||||
}
|
||||
}
|
||||
|
||||
window.roverAnalytics = {
|
||||
track: function (name, data) {
|
||||
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
|
||||
},
|
||||
identify: function (data) {
|
||||
enqueue('identify', [data || {}]);
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener('load', flushPendingCalls);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- otterlytics testing for blocking local -->
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-Da9ufxPv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<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>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "Multi Roomba Rover",
|
||||
"short_name": "MRR",
|
||||
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#020617",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/bitmap.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
// MediaMTX Configuration Validator
|
||||
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
|
||||
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
|
||||
const yaml = require('js-yaml');
|
||||
const { loadConfig } = require('../src/helpers/configLoader');
|
||||
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
|
||||
|
||||
const config = loadConfig();
|
||||
const generated = buildMediaMtxConfig({
|
||||
config,
|
||||
serverPort: process.env.PORT || 8080,
|
||||
snapshotWriterPath: process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
|
||||
});
|
||||
|
||||
/*
|
||||
Serializing is part of validation: it catches values that the builder accepted but js-yaml
|
||||
cannot represent before the installer removes the previous service configuration.
|
||||
*/
|
||||
yaml.dump(generated, { noRefs: true, lineWidth: 120 });
|
||||
process.stdout.write('MediaMTX server configuration is valid\n');
|
||||
@@ -10,6 +10,7 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
|
||||
|
||||
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||
multiTabProtection: 'verifiedOnly',
|
||||
pauseHiddenRoverVideo: false,
|
||||
nonTurnVideo: Object.freeze({
|
||||
mode: 'snapshots',
|
||||
userThreshold: 0,
|
||||
@@ -28,6 +29,15 @@ function normalizeEnum(value, allowed, fallback) {
|
||||
return allowed.has(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback) {
|
||||
/*
|
||||
YAML booleans must stay real booleans. Treating strings such as "false" as
|
||||
truthy would silently enable a bandwidth policy that the operator intended
|
||||
to disable, so invalid values fall back to the documented server default.
|
||||
*/
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeNonTurnVideo(value) {
|
||||
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const threshold = Number(raw.userThreshold);
|
||||
@@ -53,6 +63,10 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||
MULTI_TAB_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
||||
),
|
||||
pauseHiddenRoverVideo: normalizeBoolean(
|
||||
raw.pauseHiddenRoverVideo,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.pauseHiddenRoverVideo,
|
||||
),
|
||||
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
|
||||
externalSpectatorVideo: normalizeEnum(
|
||||
raw.externalSpectatorVideo,
|
||||
|
||||
@@ -1,41 +1,49 @@
|
||||
// data Paths helper
|
||||
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
|
||||
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
|
||||
const fs = require('fs');
|
||||
// Purpose: Defines the single filesystem boundary for all mutable, persistent server data.
|
||||
// Scope: Resolves the configured data root and every application-owned mutable path beneath it.
|
||||
const path = require('path');
|
||||
|
||||
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
|
||||
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
|
||||
|
||||
function pathExists(target) {
|
||||
try {
|
||||
fs.accessSync(target, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const ROVER_SNAPSHOT_DIR_NAME = 'rover-snapshots';
|
||||
const RUNTIME_DIR_NAME = 'runtime';
|
||||
|
||||
function resolveDataDir() {
|
||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
|
||||
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
|
||||
return CANONICAL_DATA_DIR;
|
||||
}
|
||||
|
||||
function resolveDataPath(fileName) {
|
||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
||||
if (configured) return path.join(path.resolve(configured), fileName);
|
||||
/*
|
||||
Always join through resolveDataDir instead of repeating environment handling
|
||||
in individual services. This is what makes one SERVER_DATA_DIR mount contain
|
||||
every database, JSON store, generated file, and persistent media directory.
|
||||
*/
|
||||
return path.join(resolveDataDir(), fileName);
|
||||
}
|
||||
|
||||
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
|
||||
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
|
||||
if (pathExists(canonicalPath)) return canonicalPath;
|
||||
if (pathExists(legacyPath)) return legacyPath;
|
||||
return canonicalPath;
|
||||
function resolveRoverSnapshotDir() {
|
||||
/*
|
||||
Snapshot production, polling, PTZ reads, and health reporting must use the
|
||||
exact same directory. Giving this shared directory a named resolver prevents
|
||||
one of those consumers from drifting back to the former /var/lib location.
|
||||
*/
|
||||
return resolveDataPath(ROVER_SNAPSHOT_DIR_NAME);
|
||||
}
|
||||
|
||||
function resolveRuntimePath(...pathSegments) {
|
||||
/*
|
||||
Disposable files are still files intentionally managed by the Node server.
|
||||
Keeping them below a named runtime directory preserves the single-root
|
||||
filesystem contract without confusing scratch files with durable stores.
|
||||
Callers remain responsible for deleting their own completed work.
|
||||
*/
|
||||
return resolveDataPath(path.join(RUNTIME_DIR_NAME, ...pathSegments));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveDataDir,
|
||||
resolveDataPath,
|
||||
resolveRoverSnapshotDir,
|
||||
resolveRuntimePath,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Data Paths Helper Tests
|
||||
// Purpose: Pins the one-root persistence contract used by local, systemd, and future container deployments.
|
||||
// Scope: Exercises path resolution only and never creates files in the real server data directory.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
resolveDataDir,
|
||||
resolveDataPath,
|
||||
resolveRoverSnapshotDir,
|
||||
resolveRuntimePath,
|
||||
} = require('./dataPaths');
|
||||
|
||||
const originalDataDir = process.env.SERVER_DATA_DIR;
|
||||
|
||||
test.afterEach(() => {
|
||||
/*
|
||||
Environment state is process-global. Restore the caller's value after each
|
||||
assertion so this focused test remains safe when it is composed with other
|
||||
tests in the same Node process later.
|
||||
*/
|
||||
if (originalDataDir === undefined) delete process.env.SERVER_DATA_DIR;
|
||||
else process.env.SERVER_DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
test('defaults every persistent path to the canonical server data directory', () => {
|
||||
delete process.env.SERVER_DATA_DIR;
|
||||
const expectedRoot = path.resolve(__dirname, '..', '..', 'data');
|
||||
|
||||
assert.equal(resolveDataDir(), expectedRoot);
|
||||
assert.equal(resolveDataPath('identity.sqlite'), path.join(expectedRoot, 'identity.sqlite'));
|
||||
assert.equal(resolveRoverSnapshotDir(), path.join(expectedRoot, 'rover-snapshots'));
|
||||
assert.equal(resolveRuntimePath('replay-builds'), path.join(expectedRoot, 'runtime', 'replay-builds'));
|
||||
});
|
||||
|
||||
test('moves every persistent path beneath SERVER_DATA_DIR when it is configured', () => {
|
||||
const configuredRoot = path.join(os.tmpdir(), 'multirover-data-path-test');
|
||||
process.env.SERVER_DATA_DIR = configuredRoot;
|
||||
|
||||
assert.equal(resolveDataDir(), path.resolve(configuredRoot));
|
||||
assert.equal(resolveDataPath('fleet-reports.sqlite'), path.join(configuredRoot, 'fleet-reports.sqlite'));
|
||||
assert.equal(resolveDataPath(path.join('replays', 'example.mp4')), path.join(configuredRoot, 'replays', 'example.mp4'));
|
||||
assert.equal(resolveRoverSnapshotDir(), path.join(configuredRoot, 'rover-snapshots'));
|
||||
assert.equal(
|
||||
resolveRuntimePath('audio-forward', 'uploads'),
|
||||
path.join(configuredRoot, 'runtime', 'audio-forward', 'uploads'),
|
||||
);
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const ptzCameraConfig = config.ptzCamera || {};
|
||||
const discordConfig = config.discord || {};
|
||||
const fleetReportsConfig = config.fleetReports || {};
|
||||
const homeAssistant = Boolean(
|
||||
asBoolean(homeAssistantConfig.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.url) &&
|
||||
@@ -100,6 +101,11 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
to run without the integration.
|
||||
*/
|
||||
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
||||
// Fleet reports are deliberately controlled by one explicit server switch.
|
||||
// Storage contents, Discord availability, or historical database files must
|
||||
// never cause the reporting UI to appear on an installation that has not
|
||||
// opted into the collector.
|
||||
fleetReports: asBoolean(fleetReportsConfig.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Site Metadata Helper
|
||||
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
|
||||
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
|
||||
const DEFAULT_SITE_METADATA = Object.freeze({
|
||||
name: 'Multi Roomba Rover',
|
||||
shortName: 'Multi Roomba Rover',
|
||||
description: 'Drive and watch remote rovers from your browser.',
|
||||
accentColor: '#38bdf8',
|
||||
backgroundColor: '#020617',
|
||||
publicUrl: null,
|
||||
});
|
||||
|
||||
const BACKGROUND_BLEND_AMOUNT = 0.15;
|
||||
|
||||
function asTrimmedString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeHexColor(value) {
|
||||
const color = asTrimmedString(value).toLowerCase();
|
||||
|
||||
/*
|
||||
Supporting both common CSS hex forms keeps the operator-facing setting
|
||||
forgiving while still preventing arbitrary CSS from being injected into
|
||||
generated HTML and SVG attributes.
|
||||
*/
|
||||
if (/^#[0-9a-f]{6}$/.test(color)) return color;
|
||||
if (/^#[0-9a-f]{3}$/.test(color)) {
|
||||
return `#${color.slice(1).split('').map((character) => character.repeat(2)).join('')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function blendHexColors(baseColor, accentColor, accentAmount) {
|
||||
const base = baseColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||
const accent = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||
|
||||
/*
|
||||
The profile color is deliberately only a tint. A full-strength profile
|
||||
color could produce a glaring PWA launch screen, while this blend preserves
|
||||
the application's established dark appearance and still makes each server
|
||||
visually recognizable.
|
||||
*/
|
||||
const channels = base.map((channel, index) =>
|
||||
Math.round(channel * (1 - accentAmount) + accent[index] * accentAmount),
|
||||
);
|
||||
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function normalizePublicUrl(value) {
|
||||
const candidate = asTrimmedString(value);
|
||||
if (!candidate) return null;
|
||||
|
||||
/*
|
||||
URL() helpfully repairs strings such as `http:192.168.0.1`, but preserving
|
||||
that typo in public metadata would conceal a configuration mistake. Require
|
||||
the conventional absolute URL form so the published address is explicit.
|
||||
*/
|
||||
if (!/^https?:\/\//i.test(candidate)) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
|
||||
/*
|
||||
Removing a trailing slash gives callers one stable base URL to combine
|
||||
with paths. Invalid values are ignored instead of producing broken
|
||||
canonical and social metadata on every page.
|
||||
*/
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getReadableAccentText(accentColor) {
|
||||
const channels = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||
const luminance = (channels[0] * 299 + channels[1] * 587 + channels[2] * 114) / 1000;
|
||||
|
||||
// A simple luminance split keeps the generated preview badge legible for both dark and light profile colors.
|
||||
return luminance > 150 ? '#020617' : '#ffffff';
|
||||
}
|
||||
|
||||
function resolveSiteMetadata(config = loadConfig()) {
|
||||
const interInstance = config?.interInstance;
|
||||
const profile = interInstance?.profile;
|
||||
const profileName = asTrimmedString(profile?.name);
|
||||
|
||||
/*
|
||||
A partially filled profile must not unexpectedly rename the site. The
|
||||
inter-instance feature must be explicitly enabled and have a usable name
|
||||
before any profile branding is applied; otherwise every value comes from
|
||||
the coherent default set above.
|
||||
*/
|
||||
if (interInstance?.enabled !== true || !profileName) {
|
||||
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
|
||||
}
|
||||
|
||||
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
|
||||
return {
|
||||
name: profileName,
|
||||
shortName: profileName,
|
||||
description: asTrimmedString(profile.description) || DEFAULT_SITE_METADATA.description,
|
||||
accentColor,
|
||||
backgroundColor: blendHexColors(
|
||||
DEFAULT_SITE_METADATA.backgroundColor,
|
||||
accentColor,
|
||||
BACKGROUND_BLEND_AMOUNT,
|
||||
),
|
||||
accentTextColor: getReadableAccentText(accentColor),
|
||||
publicUrl: normalizePublicUrl(profile.publicUrl),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SITE_METADATA,
|
||||
resolveSiteMetadata,
|
||||
};
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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 }]));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -23,8 +23,34 @@ function registerAudioForwardHooks(deps) {
|
||||
buildWhipUrl,
|
||||
videoSessions,
|
||||
startSilenceWriter,
|
||||
isMuted,
|
||||
verificationEvents,
|
||||
} = deps;
|
||||
|
||||
verificationEvents.on('change', ({ socketId } = {}) => {
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (!socket || !isMuted(socket)) return;
|
||||
|
||||
/*
|
||||
Permission checks stop new muted audio, but an upload or microphone can
|
||||
already be live when moderation changes. Stop only streams owned by this
|
||||
socket so muting does not disturb another driver's audio or unrelated
|
||||
server-generated sounds.
|
||||
*/
|
||||
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
|
||||
if (ownerSocketId === socketId) {
|
||||
stopWhipForRover(roverId, 'owner_muted');
|
||||
}
|
||||
}
|
||||
workers.forEach((worker, roverId) => {
|
||||
if (worker?.contentKind === 'upload' && worker.activeOwnerSocketId === socketId) {
|
||||
logger.info('Stopping uploaded audio because its owner was muted', { roverId, socketId });
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (!roverId) return;
|
||||
if (action === 'removed') {
|
||||
|
||||
@@ -6,9 +6,10 @@ 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 { isVerified } = require('../verificationService');
|
||||
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const { createAudioForwardPolicy } = require('./policy');
|
||||
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
||||
@@ -25,7 +26,13 @@ const streamSuffix =
|
||||
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
? audioForwardConfig.streamSuffix.trim()
|
||||
: '-fwd';
|
||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||
/*
|
||||
FIFOs and uploaded clips are disposable, but they are deliberately created
|
||||
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
|
||||
Node process from writing to an unrelated host temp directory and prevents a
|
||||
configuration value from escaping the server's filesystem boundary.
|
||||
*/
|
||||
const runtimeDir = resolveRuntimePath('audio-forward');
|
||||
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
@@ -62,6 +69,7 @@ function getAudioForwardState() {
|
||||
|
||||
const audioForwardPolicy = createAudioForwardPolicy({
|
||||
isVerified,
|
||||
isMuted,
|
||||
roverManager,
|
||||
turnService,
|
||||
streamSuffix,
|
||||
@@ -141,6 +149,8 @@ registerAudioForwardHooks({
|
||||
buildWhipUrl,
|
||||
videoSessions,
|
||||
startSilenceWriter,
|
||||
isMuted,
|
||||
verificationEvents,
|
||||
});
|
||||
|
||||
registerChargeCompleteSound({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
function createAudioForwardPolicy(deps) {
|
||||
const {
|
||||
isVerified,
|
||||
isMuted,
|
||||
roverManager,
|
||||
turnService,
|
||||
streamSuffix,
|
||||
@@ -18,6 +19,9 @@ function createAudioForwardPolicy(deps) {
|
||||
|
||||
function ensureAudioForwardPermission(socket, roverId) {
|
||||
ensureVipVerified(socket);
|
||||
if (isMuted(socket)) {
|
||||
throw new Error('Muted');
|
||||
}
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
||||
}
|
||||
@@ -26,25 +30,13 @@ function createAudioForwardPolicy(deps) {
|
||||
}
|
||||
}
|
||||
|
||||
function forcePublishStreamMode(rawUrl) {
|
||||
const value = String(rawUrl || '').trim();
|
||||
if (!value) return '';
|
||||
if (!/[?&]streamid=#!::/.test(value)) return value;
|
||||
if (/,m=publish\b/.test(value)) return value;
|
||||
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
||||
}
|
||||
|
||||
function resolveForwardUrl(roverId) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
// Rovers listen to the playback stream with a request/read URL. The VIP
|
||||
// upload path needs to publish into that same stream, so the configured
|
||||
// nested playback URL is converted to publish mode below.
|
||||
const configured = record?.meta?.media?.audioPlayback?.forwardUrl;
|
||||
if (configured) return forcePublishStreamMode(configured);
|
||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
|
||||
roverId + streamSuffix,
|
||||
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
||||
/*
|
||||
The server publishes to its own MediaMTX child, so loopback is the stable and correct
|
||||
route regardless of which hostname a rover uses to reach this machine. RTSP uses the
|
||||
same path for publish and read; ANNOUNCE/RECORD and DESCRIBE/PLAY distinguish direction.
|
||||
*/
|
||||
return `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId + streamSuffix)}`;
|
||||
}
|
||||
|
||||
function resolveForwardPathId(roverId) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Audio Forward Policy Tests
|
||||
// Purpose: Verifies that mute blocks user-owned forwarding without changing ordinary driver authorization.
|
||||
// Scope: Exercises the pure permission policy with small injected role, rover, and turn doubles.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createAudioForwardPolicy } = require('./policy');
|
||||
|
||||
function createPolicy({ verified = true, muted = false, driver = true, canDrive = true } = {}) {
|
||||
return createAudioForwardPolicy({
|
||||
isVerified: () => verified,
|
||||
isMuted: () => muted,
|
||||
roverManager: { isDriver: () => driver },
|
||||
turnService: { canDrive: () => canDrive },
|
||||
streamSuffix: '-fwd',
|
||||
mediaConfig: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects audio forwarding for a muted verified driver', () => {
|
||||
const policy = createPolicy({ muted: true });
|
||||
assert.throws(() => policy.ensureAudioForwardPermission({}, 'rover'), /Muted/);
|
||||
});
|
||||
|
||||
test('preserves normal audio forwarding for an unmuted verified driver', () => {
|
||||
const policy = createPolicy();
|
||||
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
|
||||
});
|
||||
|
||||
test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
|
||||
const policy = createPolicy();
|
||||
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
|
||||
});
|
||||
@@ -86,7 +86,7 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
exited = true;
|
||||
};
|
||||
// ChildProcess.killed only means Node successfully sent a signal, not that
|
||||
// ffmpeg actually exited. Track the real exit event so FIFO/SRT hangs still
|
||||
// ffmpeg actually exited. Track the real exit event so FIFO/publisher hangs still
|
||||
// get escalated to SIGKILL instead of making systemd wait for its timeout.
|
||||
proc.once('exit', markExited);
|
||||
try {
|
||||
@@ -145,7 +145,13 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-f',
|
||||
'mpegts',
|
||||
'rtsp',
|
||||
/*
|
||||
The MediaMTX listener accepts RTSP over TCP only. Pinning it here makes the server's
|
||||
own publisher follow the same reliable transport contract as every rover publisher.
|
||||
*/
|
||||
'-rtsp_transport',
|
||||
'tcp',
|
||||
outputUrl,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
|
||||
}
|
||||
|
||||
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 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 normalized;
|
||||
}
|
||||
|
||||
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 effective;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ADJUSTMENT_FIELDS,
|
||||
MIN_GAIN,
|
||||
MAX_GAIN,
|
||||
MIN_ADJUSTMENT_PERCENT,
|
||||
MAX_ADJUSTMENT_PERCENT,
|
||||
clampGain,
|
||||
clampMaximumAdjustmentPercent,
|
||||
clampAdjustmentPercent,
|
||||
normalizeAdjustments,
|
||||
applyAdjustments,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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 { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = require('./gainMath');
|
||||
|
||||
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('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('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('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 },
|
||||
);
|
||||
});
|
||||
@@ -7,35 +7,49 @@ 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 { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const {
|
||||
ADJUSTMENT_FIELDS,
|
||||
clampGain,
|
||||
clampMaximumAdjustmentPercent,
|
||||
normalizeAdjustments,
|
||||
applyAdjustments,
|
||||
} = require('./gainMath');
|
||||
|
||||
const audioLevelsEvents = new EventEmitter();
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||
const config = loadConfig();
|
||||
const configuredDefaults = config.audioLevels || {};
|
||||
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),
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||
configuredDefaults.maxPersonalAdjustmentPercent,
|
||||
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||
),
|
||||
};
|
||||
|
||||
function clampGain(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(4, num));
|
||||
}
|
||||
|
||||
function normalizeStore(raw = {}) {
|
||||
return {
|
||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||
raw.maxPersonalAdjustmentPercent,
|
||||
DEFAULTS.maxPersonalAdjustmentPercent,
|
||||
),
|
||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
|
||||
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,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);
|
||||
@@ -71,18 +90,79 @@ function getAudioLevels() {
|
||||
hornGain: current.hornGain,
|
||||
ttsGain: current.ttsGain,
|
||||
forwardGain: current.forwardGain,
|
||||
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
|
||||
updatedAt: current.updatedAt,
|
||||
updatedBy: current.updatedBy,
|
||||
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
|
||||
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function emitChange(reason = 'update') {
|
||||
function emitChange(reason = 'update', extra = {}) {
|
||||
audioLevelsEvents.emit('change', {
|
||||
reason,
|
||||
levels: getAudioLevels(),
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function getAdminLimits() {
|
||||
const current = loadState();
|
||||
return {
|
||||
hornGain: current.hornGain,
|
||||
ttsGain: current.ttsGain,
|
||||
forwardGain: current.forwardGain,
|
||||
};
|
||||
}
|
||||
|
||||
function canUsePersonalAdjustments(socket) {
|
||||
if (isAdmin(socket)) return true;
|
||||
const userId = getUserIdForSocket(socket);
|
||||
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
|
||||
}
|
||||
|
||||
function getAdjustmentsForSocket(socket) {
|
||||
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
|
||||
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
|
||||
}
|
||||
|
||||
function getEffectiveLevelsForSocket(socket) {
|
||||
return applyAdjustments(getAdminLimits(), getAdjustmentsForSocket(socket));
|
||||
}
|
||||
|
||||
/*
|
||||
The rover applies gain as three ALSA master controls, so only one set of gains
|
||||
can be live per rover at a time. That is not a limitation in practice: horn,
|
||||
TTS, and mic forwarding are all restricted to the socket currently holding
|
||||
audio control, so pushing that socket's resolved gains gives genuinely
|
||||
per-user volume. When nobody owns audio the global admin gains apply.
|
||||
*/
|
||||
function resolveAudioOwnerSocket(roverId) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record) return null;
|
||||
const driverIds = Array.from(record.drivers || []);
|
||||
if (!driverIds.length) return null;
|
||||
|
||||
// Required lazily: turnService reaches back into roverManager during startup.
|
||||
let activeSocketId = null;
|
||||
try {
|
||||
activeSocketId = require('../turnService').getActiveDrivers()[roverId] || null;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resolve active driver for audio levels', roverId, err.message);
|
||||
}
|
||||
|
||||
const chosenId = activeSocketId && driverIds.includes(activeSocketId)
|
||||
? activeSocketId
|
||||
: (driverIds.length === 1 ? driverIds[0] : null);
|
||||
if (!chosenId) return null;
|
||||
return io.sockets.sockets.get(chosenId) || null;
|
||||
}
|
||||
|
||||
function resolveLevelsForRover(roverId) {
|
||||
const owner = resolveAudioOwnerSocket(roverId);
|
||||
return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits();
|
||||
}
|
||||
|
||||
function pushLevelsToRover(roverId) {
|
||||
if (!roverId) return;
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
@@ -90,7 +170,7 @@ function pushLevelsToRover(roverId) {
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'audioLevels',
|
||||
audioLevels: getAudioLevels(),
|
||||
audioLevels: resolveLevelsForRover(roverId),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
||||
@@ -105,6 +185,11 @@ function pushLevelsToAllRovers() {
|
||||
});
|
||||
}
|
||||
|
||||
function pushLevelsForSocket(socket) {
|
||||
if (!socket) return;
|
||||
roverManager.getRoversForSocket(socket.id).forEach((roverId) => pushLevelsToRover(roverId));
|
||||
}
|
||||
|
||||
function setAudioLevels(input = {}, actor = null) {
|
||||
const current = loadState();
|
||||
const next = {
|
||||
@@ -121,12 +206,94 @@ function setAudioLevels(input = {}, actor = null) {
|
||||
return getAudioLevels();
|
||||
}
|
||||
|
||||
function setMaxPersonalAdjustmentPercent(value, actor = null) {
|
||||
const current = loadState();
|
||||
const next = {
|
||||
...current,
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
|
||||
adjustmentRangeUpdatedAt: Date.now(),
|
||||
adjustmentRangeUpdatedBy: actor,
|
||||
};
|
||||
persistState(next);
|
||||
/*
|
||||
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('personal_adjustment_range_set');
|
||||
return loadState().maxPersonalAdjustmentPercent;
|
||||
}
|
||||
|
||||
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('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
|
||||
return getAudioAdjustmentStateForSocket(socket);
|
||||
}
|
||||
|
||||
/*
|
||||
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 getAudioAdjustmentStateForSocket(socket) {
|
||||
const allowed = canUsePersonalAdjustments(socket);
|
||||
const maximum = loadState().maxPersonalAdjustmentPercent;
|
||||
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
|
||||
return {
|
||||
values,
|
||||
allowed,
|
||||
maxAdjustmentPercent: maximum,
|
||||
effective: applyAdjustments(getAdminLimits(), values),
|
||||
baseLevels: getAdminLimits(),
|
||||
};
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (action === 'upsert' && roverId) {
|
||||
pushLevelsToRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
Whoever owns a rover's audio determines which gains are live, so the rover has
|
||||
to be re-pushed whenever that ownership moves: joining or leaving a rover, and
|
||||
every turn rotation.
|
||||
*/
|
||||
roverManager.managerEvents.on('driver', ({ roverId } = {}) => {
|
||||
if (roverId) pushLevelsToRover(roverId);
|
||||
});
|
||||
|
||||
setImmediate(() => {
|
||||
try {
|
||||
require('../turnService').turnEvents.on('queue', ({ roverId } = {}) => {
|
||||
if (roverId) pushLevelsToRover(roverId);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to subscribe to turn changes for audio levels', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
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() });
|
||||
@@ -144,13 +311,49 @@ io.on('connection', (socket) => {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:setPersonalAdjustmentRange', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
if (!isAdmin(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
const actor = socket?.data?.user?.username || null;
|
||||
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
|
||||
cb({ success: true, maxPersonalAdjustmentPercent });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
|
||||
try {
|
||||
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadState();
|
||||
|
||||
module.exports = {
|
||||
ADJUSTMENT_FIELDS,
|
||||
PERSONAL_ADJUSTMENT_PERMISSION,
|
||||
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||
getAudioLevels,
|
||||
setAudioLevels,
|
||||
setMaxPersonalAdjustmentPercent,
|
||||
setSocketAdjustments,
|
||||
getEffectiveLevelsForSocket,
|
||||
getAudioAdjustmentStateForSocket,
|
||||
pushLevelsToRover,
|
||||
audioLevelsEvents,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user