mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
351cb24458 | ||
|
|
679563862d | ||
|
|
8655cde0f1 | ||
|
|
12090f23be | ||
|
|
557b4b81a2 | ||
|
|
0add90714b | ||
|
|
fe64ec7758 | ||
|
|
10f71edaf1 | ||
|
|
e97d4056fa | ||
|
|
c228bb107f | ||
|
|
06aeca660b | ||
|
|
4bd228547a | ||
|
|
35561495b4 | ||
|
|
8a9205b5b7 | ||
|
|
a6c569ada4 | ||
|
|
0d352d326d | ||
|
|
7bc08af160 | ||
|
|
9177e53fbf | ||
|
|
5be5ad3b17 | ||
|
|
99bc00e96b | ||
|
|
002b174259 | ||
|
|
e28ccc5e66 | ||
|
|
efae430d65 | ||
|
|
0c9df78070 |
+6
-1
@@ -21,15 +21,20 @@ 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
|
||||
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,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
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
- not allowed
|
||||
- snapshots
|
||||
- non-turn video
|
||||
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots)
|
||||
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots after the user threshold is exceeded)
|
||||
- live (rover non-active turn holders and PTZ non-operators can get full video)
|
||||
- userThreshold (snapshots turn on when controllable users exceed this number)
|
||||
- external spectator video
|
||||
- snapshots (external spectators are only allowed snapshots)
|
||||
- live (external spectators can get full video)
|
||||
@@ -23,7 +24,9 @@
|
||||
```yaml
|
||||
bandwidthSavings:
|
||||
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
|
||||
nonTurnVideo: "snapshots" # snapshots | live
|
||||
nonTurnVideo:
|
||||
mode: "snapshots" # snapshots | live
|
||||
userThreshold: 0 # snapshots turn on when controllable users exceed this number
|
||||
externalSpectatorVideo: "snapshots" # snapshots | live
|
||||
externalSpectatorAccess: "on" # off | on | verifiedOnly | admin
|
||||
```
|
||||
|
||||
@@ -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,10 +67,21 @@ 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"
|
||||
# Live video for users who are attached to a source but do not currently own
|
||||
# its active turn. "snapshots" saves upload bandwidth; "live" allows full
|
||||
# video whenever the normal mode/visibility rules allow it.
|
||||
nonTurnVideo: "snapshots"
|
||||
# Disconnect rover video when its player is outside the viewport or the web
|
||||
# page is in a background browser tab. Rover audio is a separate stream and
|
||||
# remains connected. /mini intentionally keeps its existing always-warm video
|
||||
# behavior regardless of this option.
|
||||
pauseHiddenRoverVideo: false
|
||||
# Video for users who are attached to a source but do not currently own its
|
||||
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
|
||||
# whenever the normal mode/visibility rules allow it.
|
||||
nonTurnVideo:
|
||||
mode: "snapshots"
|
||||
# Snapshot mode activates only when controllable users exceed this number.
|
||||
# A controllable user is attached to a rover or PTZ as operator/queue, not a
|
||||
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
|
||||
# actually attached to a controllable source.
|
||||
userThreshold: 0
|
||||
# Live video for spectators outside the local network. Local spectators are
|
||||
# not restricted by this switch because LAN traffic is not the upload limit.
|
||||
externalSpectatorVideo: "snapshots"
|
||||
@@ -81,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
|
||||
@@ -171,6 +194,11 @@ kinect:
|
||||
# camera cache; it only gates browser-requested broadcasts.
|
||||
captureCooldownMs: 10000
|
||||
|
||||
balanceBoard:
|
||||
# The server installer always prepares Bluetooth and the kernel driver. This
|
||||
# switch only starts the service and shows its small live-weight panel.
|
||||
enabled: false
|
||||
|
||||
buttonBox:
|
||||
enabled: false
|
||||
|
||||
@@ -216,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');
|
||||
@@ -46,8 +50,13 @@ require('./src/services/buttonBoxService');
|
||||
require('./src/services/barcodeScannerService');
|
||||
require('./src/services/barcodeGameService');
|
||||
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.
|
||||
|
||||
+64
-42
@@ -8,14 +8,14 @@ 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"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -30,8 +30,9 @@ fi
|
||||
TARGET_USER="$SUDO_USER"
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR="$SCRIPT_DIR"
|
||||
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"
|
||||
|
||||
@@ -134,7 +135,11 @@ dnf install -y \
|
||||
gstreamer1-rtsp-server \
|
||||
libfreenect \
|
||||
libfreenect-devel \
|
||||
libusb1-devel >/dev/null
|
||||
libusb1-devel \
|
||||
bluez \
|
||||
wiiuse \
|
||||
wiiuse-devel \
|
||||
libcap >/dev/null
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
|
||||
@@ -166,12 +171,43 @@ if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
|
||||
fi
|
||||
|
||||
if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
|
||||
echo " Building native Balance Board bridge..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make"
|
||||
if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then
|
||||
echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only this small audited bridge needs the management socket used for the
|
||||
# board's raw six-byte pairing PIN and the two reserved HID PSMs used by
|
||||
# front-button reconnects. Never grant either capability to node or the full
|
||||
# multirover service executable.
|
||||
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||
fi
|
||||
|
||||
# Bluetoothd remains responsible for discovery and the one-time bond, but its
|
||||
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
|
||||
# 0x13 before the Balance Board worker can listen for the board's front-button
|
||||
# reconnect. This dedicated rover server gives those two HID listeners to the
|
||||
# worker; every other BlueZ profile is left enabled. Clearing ExecStart is
|
||||
# required by systemd before replacing the vendor unit's command in a drop-in.
|
||||
install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR"
|
||||
cat > "$BLUETOOTH_OVERRIDE" <<'EOF'
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input
|
||||
EOF
|
||||
chmod 0644 "$BLUETOOTH_OVERRIDE"
|
||||
systemctl daemon-reload
|
||||
systemctl enable bluetooth.service
|
||||
systemctl restart bluetooth.service
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
@@ -220,54 +256,40 @@ 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
|
||||
|
||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=Multi-Roomba Rover control server
|
||||
After=network-online.target mediamtx.service
|
||||
Wants=network-online.target
|
||||
After=network-online.target bluetooth.service
|
||||
Wants=network-online.target bluetooth.service
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
@@ -277,6 +299,7 @@ Environment=NODE_ENV=production
|
||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
@@ -286,21 +309,20 @@ 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."
|
||||
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
||||
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
|
||||
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
|
||||
|
||||
@@ -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
|
||||
@@ -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-B8ElczOE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ZpgWUPKf.css">
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-C2LCvBX6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CqaMtmWx.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,7 +10,11 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
|
||||
|
||||
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||
multiTabProtection: 'verifiedOnly',
|
||||
nonTurnVideo: 'snapshots',
|
||||
pauseHiddenRoverVideo: false,
|
||||
nonTurnVideo: Object.freeze({
|
||||
mode: 'snapshots',
|
||||
userThreshold: 0,
|
||||
}),
|
||||
externalSpectatorVideo: 'snapshots',
|
||||
externalSpectatorAccess: 'on',
|
||||
});
|
||||
@@ -25,6 +29,32 @@ 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);
|
||||
/*
|
||||
userThreshold is intentionally "greater than", not "greater than or equal".
|
||||
A value of 4 means the first four controllable users can keep live non-turn
|
||||
video, and the fifth controllable user activates snapshot saving. Invalid
|
||||
or negative values fall back to zero, which preserves always-on snapshots
|
||||
for any real non-turn participant.
|
||||
*/
|
||||
const userThreshold = Number.isFinite(threshold) ? Math.max(0, Math.floor(threshold)) : 0;
|
||||
return {
|
||||
mode: normalizeEnum(raw.mode, VIDEO_MODES, DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo.mode),
|
||||
userThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||
const raw = config.bandwidthSavings || {};
|
||||
return {
|
||||
@@ -33,11 +63,11 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||
MULTI_TAB_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
||||
),
|
||||
nonTurnVideo: normalizeEnum(
|
||||
raw.nonTurnVideo,
|
||||
VIDEO_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo,
|
||||
pauseHiddenRoverVideo: normalizeBoolean(
|
||||
raw.pauseHiddenRoverVideo,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.pauseHiddenRoverVideo,
|
||||
),
|
||||
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
|
||||
externalSpectatorVideo: normalizeEnum(
|
||||
raw.externalSpectatorVideo,
|
||||
VIDEO_MODES,
|
||||
@@ -72,8 +102,16 @@ function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } =
|
||||
return !isVerified && !isAdmin;
|
||||
}
|
||||
|
||||
function shouldUseSnapshotsForNonTurnVideo() {
|
||||
return getBandwidthSavingsPolicy().nonTurnVideo === 'snapshots';
|
||||
function shouldUseSnapshotsForNonTurnVideo({ controllableUserCount = 0 } = {}) {
|
||||
const { nonTurnVideo } = getBandwidthSavingsPolicy();
|
||||
if (nonTurnVideo.mode !== 'snapshots') return false;
|
||||
/*
|
||||
The threshold is evaluated centrally so MediaMTX auth, socket-issued video
|
||||
tokens, PTZ authorization, and browser session state all agree. Using a
|
||||
strict greater-than comparison makes the configured value read like the
|
||||
maximum number of controllable users allowed before snapshots start.
|
||||
*/
|
||||
return Math.max(0, Number(controllableUserCount) || 0) > nonTurnVideo.userThreshold;
|
||||
}
|
||||
|
||||
function shouldUseSnapshotsForExternalSpectatorVideo() {
|
||||
|
||||
@@ -46,11 +46,13 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
const kinectConfig = config.kinect || {};
|
||||
const buttonBoxConfig = config.buttonBox || {};
|
||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||
const balanceBoardConfig = config.balanceBoard || {};
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const ptzCameraConfig = config.ptzCamera || {};
|
||||
const discordConfig = config.discord || {};
|
||||
const fleetReportsConfig = config.fleetReports || {};
|
||||
const homeAssistant = Boolean(
|
||||
asBoolean(homeAssistantConfig.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.url) &&
|
||||
@@ -68,6 +70,10 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
kinect: asBoolean(kinectConfig.enabled),
|
||||
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||
barcodeScanner,
|
||||
// The worker performs its own runtime availability reporting. Advertising
|
||||
// the feature from the explicit config switch lets the UI show useful
|
||||
// commissioning and hardware-error states even before a board is paired.
|
||||
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||
lift: Boolean(
|
||||
homeAssistant &&
|
||||
@@ -95,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') {
|
||||
|
||||
@@ -8,7 +8,7 @@ const logger = require('../../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
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');
|
||||
@@ -62,6 +62,7 @@ function getAudioForwardState() {
|
||||
|
||||
const audioForwardPolicy = createAudioForwardPolicy({
|
||||
isVerified,
|
||||
isMuted,
|
||||
roverManager,
|
||||
turnService,
|
||||
streamSuffix,
|
||||
@@ -141,6 +142,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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Balance Board Hardware Bridge
|
||||
// Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events.
|
||||
// Scope: Owns process lifecycle, restart recovery, shutdown, and protocol validation; scale policy remains in index.js.
|
||||
const { spawn } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const path = require('path');
|
||||
|
||||
const WORKER_PATH =
|
||||
process.env.BALANCE_BOARD_WORKER ||
|
||||
path.join(__dirname, 'native', 'balance_board_worker');
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const STDERR_LOG_INTERVAL_MS = 5000;
|
||||
|
||||
function createBalanceBoardHardware({ logger, address = '', simulate = false } = {}) {
|
||||
const events = new EventEmitter();
|
||||
let worker = null;
|
||||
let stdoutBuffer = '';
|
||||
let stopped = false;
|
||||
let restarting = false;
|
||||
let restartTimer = null;
|
||||
let lastStderrLogAt = 0;
|
||||
let suppressedStderrLines = 0;
|
||||
let currentAddress = address;
|
||||
|
||||
function emitProtocolError(message) {
|
||||
events.emit('message', {
|
||||
type: 'status',
|
||||
state: 'error',
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
function processStdout(chunk) {
|
||||
stdoutBuffer += chunk.toString('utf8');
|
||||
let newline = stdoutBuffer.indexOf('\n');
|
||||
while (newline !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newline).trim();
|
||||
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
||||
if (line) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
throw new Error('message needs a type');
|
||||
}
|
||||
events.emit('message', message);
|
||||
} catch (err) {
|
||||
// A corrupted stdout line means measurement framing can no longer be
|
||||
// trusted. Surface the exact line rather than silently discarding a
|
||||
// potential hardware failure that would otherwise look like zero kg.
|
||||
emitProtocolError(`balance board worker returned invalid JSON: ${err.message}`);
|
||||
logger?.warn?.('Balance Board worker protocol error', { line, error: err.message });
|
||||
}
|
||||
}
|
||||
newline = stdoutBuffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRestart() {
|
||||
if (stopped || restartTimer) return;
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = null;
|
||||
start();
|
||||
}, RESTART_DELAY_MS);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (stopped || (worker && !worker.killed)) return;
|
||||
stdoutBuffer = '';
|
||||
|
||||
const child = spawn(WORKER_PATH, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
BALANCE_BOARD_ADDRESS: currentAddress || '',
|
||||
BALANCE_BOARD_SIMULATE: simulate ? 'cycle' : '',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
worker = child;
|
||||
|
||||
child.stdout.on('data', processStdout);
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString('utf8').trim();
|
||||
if (!text) return;
|
||||
const now = Date.now();
|
||||
if (now - lastStderrLogAt >= STDERR_LOG_INTERVAL_MS) {
|
||||
const suffix = suppressedStderrLines
|
||||
? ` (${suppressedStderrLines} worker stderr lines suppressed)`
|
||||
: '';
|
||||
logger?.warn?.(`Balance Board worker: ${text}${suffix}`);
|
||||
lastStderrLogAt = now;
|
||||
suppressedStderrLines = 0;
|
||||
} else {
|
||||
suppressedStderrLines += 1;
|
||||
}
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
if (worker === child) worker = null;
|
||||
emitProtocolError(`balance board worker failed to start: ${err.message}`);
|
||||
scheduleRestart();
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
if (worker === child) worker = null;
|
||||
if (!stopped) {
|
||||
// Admin unpair deliberately replaces the worker with an empty address.
|
||||
// Do not turn that expected exit into a red hardware-error state while
|
||||
// still using the normal restart scheduler for the replacement.
|
||||
if (!restarting) emitProtocolError(`balance board worker exited (${signal || code})`);
|
||||
restarting = false;
|
||||
scheduleRestart();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
restarting = false;
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
}
|
||||
if (!worker) return;
|
||||
const child = worker;
|
||||
worker = null;
|
||||
try {
|
||||
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||
} catch (_err) {
|
||||
// The worker may have already closed stdin while its exit event is still
|
||||
// queued. SIGTERM below remains the reliable cleanup path.
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
// bluetoothctl may still be finishing a bounded pairing command inside a
|
||||
// worker thread. Do not let that delay server shutdown indefinitely.
|
||||
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||
}, 1500).unref();
|
||||
}
|
||||
|
||||
function restart() {
|
||||
if (stopped) return;
|
||||
if (!worker) {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = worker;
|
||||
restarting = true;
|
||||
try {
|
||||
// An admin forget changes the address used in the child environment. A
|
||||
// controlled restart lets the replacement worker start with that new
|
||||
// value, while the existing close handler remains the single owner of
|
||||
// delayed respawn and avoids overlapping Bluetooth listeners.
|
||||
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||
} catch (_err) {
|
||||
// The child may have already closed stdin; SIGTERM below still guarantees
|
||||
// that it cannot keep listening for the address that was just forgotten.
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||
}, 1500).unref();
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setAddress(nextAddress) {
|
||||
// The factory can be created before first commissioning. Preserve the
|
||||
// newly paired address for later bridge restarts in the same Node process
|
||||
// instead of reverting the replacement worker to discovery mode.
|
||||
currentAddress = typeof nextAddress === 'string' ? nextAddress.trim().toUpperCase() : '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBalanceBoardHardware,
|
||||
};
|
||||
@@ -0,0 +1,584 @@
|
||||
// Balance Board Service
|
||||
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
|
||||
// Scope: Stores pairing and admin zero calibration, then publishes status plus live four-corner weight.
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('balanceBoardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { createBalanceBoardHardware } = require('./hardware');
|
||||
|
||||
const events = new EventEmitter();
|
||||
const enabled = isFeatureEnabled('balanceBoard');
|
||||
const rawConfig = loadConfig().balanceBoard || {};
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('balance-board.json');
|
||||
const FRAME_ROOM = 'balance-board-viewers';
|
||||
const CORNER_KEYS = ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'];
|
||||
const ZERO_SAMPLE_COUNT = 10;
|
||||
const ZERO_SAMPLE_INTERVAL_MS = 1000;
|
||||
const ZERO_MAX_SAMPLE_AGE_MS = 1500;
|
||||
const ZERO_MAX_COMBINED_RANGE_KG = 0.5;
|
||||
const RECORD_PERSIST_DELAY_MS = 1000;
|
||||
const execFileAsync = promisify(execFile);
|
||||
const ALERT_COLOR = '#38bdf8';
|
||||
|
||||
function emptyZeroCorners() {
|
||||
return Object.fromEntries(CORNER_KEYS.map((key) => [key, 0]));
|
||||
}
|
||||
|
||||
function normalizeStoredCorners(value) {
|
||||
if (!value || typeof value !== 'object') return emptyZeroCorners();
|
||||
return Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||
const number = Number(value[key]);
|
||||
return [key, Number.isFinite(number) ? Math.max(0, number) : 0];
|
||||
}));
|
||||
}
|
||||
|
||||
function emptyStore() {
|
||||
return {
|
||||
address: '',
|
||||
zeroCorners: emptyZeroCorners(),
|
||||
zeroedAt: null,
|
||||
recordKg: 0,
|
||||
recordedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
|
||||
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null;
|
||||
const recordKg = Number.isFinite(Number(parsed?.recordKg))
|
||||
? roundedWeight(parsed.recordKg)
|
||||
: 0;
|
||||
const recordedAt = Number.isFinite(Number(parsed?.recordedAt))
|
||||
? Number(parsed.recordedAt)
|
||||
: null;
|
||||
return {
|
||||
address,
|
||||
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
|
||||
zeroedAt,
|
||||
recordKg,
|
||||
recordedAt: recordKg > 0 ? recordedAt : null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
|
||||
return emptyStore();
|
||||
}
|
||||
}
|
||||
|
||||
function persistStore() {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(temporary, STORE_PATH);
|
||||
}
|
||||
|
||||
function roundedWeight(value) {
|
||||
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function cornerWeightsKg(corners = {}) {
|
||||
// Preserve wiiuse's factory-calibrated load cells in kilograms. The separate
|
||||
// admin zero calibration below is an installation baseline layered on top of
|
||||
// this factory conversion; it must never replace the hardware calibration.
|
||||
return {
|
||||
topRight: roundedWeight((Number(corners.topRight) || 0) / 100),
|
||||
bottomRight: roundedWeight((Number(corners.bottomRight) || 0) / 100),
|
||||
topLeft: roundedWeight((Number(corners.topLeft) || 0) / 100),
|
||||
bottomLeft: roundedWeight((Number(corners.bottomLeft) || 0) / 100),
|
||||
};
|
||||
}
|
||||
|
||||
function subtractZero(rawCorners) {
|
||||
const baseline = store.zeroedAt ? store.zeroCorners : emptyZeroCorners();
|
||||
return Object.fromEntries(CORNER_KEYS.map((key) => [
|
||||
key,
|
||||
roundedWeight(Math.max(0, rawCorners[key] - baseline[key])),
|
||||
]));
|
||||
}
|
||||
|
||||
function totalCornerWeight(corners) {
|
||||
return roundedWeight(CORNER_KEYS.reduce((total, key) => total + corners[key], 0));
|
||||
}
|
||||
|
||||
let store = enabled ? loadStore() : emptyStore();
|
||||
let hardware = null;
|
||||
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
|
||||
let detail = enabled
|
||||
? (store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.')
|
||||
: 'Balance Board support is disabled.';
|
||||
let connected = false;
|
||||
let batteryPercent = null;
|
||||
let latestFrame = null;
|
||||
let latestRawCorners = null;
|
||||
let latestRawFrameAt = 0;
|
||||
let zeroTimer = null;
|
||||
let recordPersistTimer = null;
|
||||
let zeroSamples = [];
|
||||
let zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
let previousWorkerState = '';
|
||||
let lastAlertKey = '';
|
||||
let unpairing = false;
|
||||
|
||||
function sendRawAlert(state, message = '') {
|
||||
const rawMessage = message ? `${state}: ${message}` : state;
|
||||
if (rawMessage === lastAlertKey) return;
|
||||
lastAlertKey = rawMessage;
|
||||
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
|
||||
}
|
||||
|
||||
function sendStatusAlert(workerState, message = '') {
|
||||
const shouldAlert =
|
||||
workerState === 'connected' ||
|
||||
workerState === 'sleeping' ||
|
||||
workerState === 'connection-failed' ||
|
||||
workerState === 'error' ||
|
||||
(workerState === 'waiting' && previousWorkerState === 'connected');
|
||||
|
||||
previousWorkerState = workerState;
|
||||
if (!shouldAlert) return;
|
||||
|
||||
// Keep the alert at the same system-level boundary as the worker protocol:
|
||||
// state first, followed by its exact detail when one exists. The service does
|
||||
// not reinterpret failures as friendlier product copy, but still collapses
|
||||
// identical retries so a failing reconnect cannot flood the activity feed.
|
||||
sendRawAlert(workerState, message);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
enabled,
|
||||
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
|
||||
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
|
||||
connected,
|
||||
status,
|
||||
detail,
|
||||
batteryPercent,
|
||||
recordKg: store.recordKg,
|
||||
recordedAt: store.recordedAt,
|
||||
calibration: {
|
||||
calibrated: Boolean(store.zeroedAt),
|
||||
zeroedAt: store.zeroedAt,
|
||||
...zeroProgress,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearRecordPersistTimer() {
|
||||
if (!recordPersistTimer) return;
|
||||
clearTimeout(recordPersistTimer);
|
||||
recordPersistTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRecordPersistence() {
|
||||
clearRecordPersistTimer();
|
||||
|
||||
// A person driving onto the board produces many successively larger frames.
|
||||
// Waiting until the maximum has stopped changing prevents a synchronous JSON
|
||||
// rewrite for every 20 Hz sensor frame while still saving a settled record
|
||||
// promptly enough to survive an ordinary service restart.
|
||||
recordPersistTimer = setTimeout(() => {
|
||||
recordPersistTimer = null;
|
||||
persistStore();
|
||||
}, RECORD_PERSIST_DELAY_MS);
|
||||
recordPersistTimer.unref?.();
|
||||
}
|
||||
|
||||
function publishLatestFrame() {
|
||||
if (!latestFrame) return;
|
||||
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
|
||||
}
|
||||
|
||||
function resetWeightRecord() {
|
||||
clearRecordPersistTimer();
|
||||
|
||||
// Reset means "start measuring the record from now." If the board currently
|
||||
// has a load, that current measurement is the first candidate in the new
|
||||
// period. Saving it immediately avoids briefly showing zero before the next
|
||||
// live frame restores the same weight as the record.
|
||||
const currentWeight = connected && latestFrame ? roundedWeight(latestFrame.totalKg) : 0;
|
||||
store.recordKg = currentWeight;
|
||||
store.recordedAt = currentWeight > 0 ? Date.now() : null;
|
||||
persistStore();
|
||||
|
||||
if (latestFrame) {
|
||||
latestFrame = {
|
||||
...latestFrame,
|
||||
recordKg: store.recordKg,
|
||||
recordedAt: store.recordedAt,
|
||||
};
|
||||
publishLatestFrame();
|
||||
}
|
||||
events.emit('change', { state: getState() });
|
||||
sendRawAlert('record-reset');
|
||||
}
|
||||
|
||||
function updateStatus(nextStatus, nextDetail) {
|
||||
const normalizedStatus = String(nextStatus || 'unknown');
|
||||
const normalizedDetail = String(nextDetail || '');
|
||||
if (status === normalizedStatus && detail === normalizedDetail) return;
|
||||
status = normalizedStatus;
|
||||
detail = normalizedDetail;
|
||||
events.emit('change', { state: getState() });
|
||||
}
|
||||
|
||||
function publishCalibrationState() {
|
||||
// Calibration progress belongs in the ordinary session payload because it
|
||||
// changes only once per second for ten seconds. Live 20 Hz weights remain in
|
||||
// their dedicated room and never trigger a full-session broadcast.
|
||||
events.emit('change', { state: getState() });
|
||||
}
|
||||
|
||||
function clearZeroTimer() {
|
||||
if (!zeroTimer) return;
|
||||
clearInterval(zeroTimer);
|
||||
zeroTimer = null;
|
||||
}
|
||||
|
||||
function failZeroCalibration(error, { alert = true } = {}) {
|
||||
clearZeroTimer();
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: String(error || 'Calibration failed'),
|
||||
};
|
||||
publishCalibrationState();
|
||||
if (alert) sendRawAlert('zero-failed', zeroProgress.error);
|
||||
}
|
||||
|
||||
function finishZeroCalibration() {
|
||||
clearZeroTimer();
|
||||
|
||||
// A single average could hide movement that returns to its starting point.
|
||||
// Sum every corner's complete ten-second range before accepting the result so
|
||||
// distributed movement cannot hide below four independent thresholds. Retain
|
||||
// three decimals so averaging ten centi-kilogram samples does not throw away
|
||||
// useful sub-centi-kilogram precision in the persisted baseline.
|
||||
const combinedRange = CORNER_KEYS.reduce((totalRange, key) => {
|
||||
const values = zeroSamples.map((sample) => sample[key]);
|
||||
return totalRange + Math.max(...values) - Math.min(...values);
|
||||
}, 0);
|
||||
if (combinedRange > ZERO_MAX_COMBINED_RANGE_KG) {
|
||||
failZeroCalibration('Load moved during the ten-second calibration.');
|
||||
return;
|
||||
}
|
||||
|
||||
store.zeroCorners = Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||
const average = zeroSamples.reduce((sum, sample) => sum + sample[key], 0) /
|
||||
zeroSamples.length;
|
||||
return [key, Math.round(average * 1000) / 1000];
|
||||
}));
|
||||
store.zeroedAt = Date.now();
|
||||
// A new zero changes the meaning of every adjusted weight, so an old record
|
||||
// cannot be compared with measurements under the new baseline.
|
||||
clearRecordPersistTimer();
|
||||
store.recordKg = 0;
|
||||
store.recordedAt = null;
|
||||
persistStore();
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: ZERO_SAMPLE_COUNT,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
publishCalibrationState();
|
||||
sendRawAlert('zeroed');
|
||||
}
|
||||
|
||||
function takeZeroSample() {
|
||||
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||
failZeroCalibration('Live Balance Board data stopped during calibration.');
|
||||
return;
|
||||
}
|
||||
|
||||
zeroSamples.push({ ...latestRawCorners });
|
||||
zeroProgress = {
|
||||
active: true,
|
||||
samplesCollected: zeroSamples.length,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
publishCalibrationState();
|
||||
if (zeroSamples.length >= ZERO_SAMPLE_COUNT) finishZeroCalibration();
|
||||
}
|
||||
|
||||
function startZeroCalibration() {
|
||||
if (zeroProgress.active) throw new Error('Balance Board zero calibration is already running');
|
||||
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||
throw new Error('The Balance Board must be connected and sending weight data');
|
||||
}
|
||||
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: true,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
publishCalibrationState();
|
||||
sendRawAlert('zeroing');
|
||||
// Delaying the first sample by one interval makes this a real ten-second
|
||||
// calibration rather than ten rapid reads followed by nine seconds of UI.
|
||||
zeroTimer = setInterval(takeZeroSample, ZERO_SAMPLE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function processFrame(message = {}) {
|
||||
const rawCorners = cornerWeightsKg(message.corners);
|
||||
latestRawCorners = rawCorners;
|
||||
latestRawFrameAt = Date.now();
|
||||
if (Number.isFinite(Number(message.batteryPercent))) {
|
||||
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
|
||||
}
|
||||
|
||||
connected = true;
|
||||
updateStatus('connected', 'Live weight is updating.');
|
||||
const adjustedCorners = subtractZero(rawCorners);
|
||||
const totalKg = totalCornerWeight(adjustedCorners);
|
||||
if (totalKg > store.recordKg) {
|
||||
// Store only adjusted weight so the displayed record uses the same admin
|
||||
// zero baseline as the live total and all four corner readings.
|
||||
store.recordKg = totalKg;
|
||||
store.recordedAt = Date.now();
|
||||
scheduleRecordPersistence();
|
||||
}
|
||||
latestFrame = {
|
||||
totalKg,
|
||||
corners: adjustedCorners,
|
||||
batteryPercent,
|
||||
recordKg: store.recordKg,
|
||||
recordedAt: store.recordedAt,
|
||||
};
|
||||
publishLatestFrame();
|
||||
}
|
||||
|
||||
function handleWorkerMessage(message = {}) {
|
||||
if (message.type === 'frame') {
|
||||
processFrame(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'paired') {
|
||||
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
|
||||
if (address && address !== store.address) {
|
||||
store.address = address;
|
||||
// A zero baseline belongs to one physical board and whatever permanent
|
||||
// platform/load was present when an admin calibrated it. Never carry that
|
||||
// baseline across commissioning a different Bluetooth identity.
|
||||
store.zeroCorners = emptyZeroCorners();
|
||||
store.zeroedAt = null;
|
||||
clearRecordPersistTimer();
|
||||
store.recordKg = 0;
|
||||
store.recordedAt = null;
|
||||
persistStore();
|
||||
}
|
||||
hardware?.setAddress(address);
|
||||
sendRawAlert('paired');
|
||||
updateStatus('connecting', 'Paired. Connecting to the board now.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== 'status') return;
|
||||
const workerState = String(message.state || 'unknown');
|
||||
sendStatusAlert(workerState, message.error || '');
|
||||
if (workerState === 'commissioning') {
|
||||
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||
} else if (workerState === 'discovering') {
|
||||
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
|
||||
} else if (workerState === 'device-detected') {
|
||||
// Preserve the worker's exact identification stage instead of leaving the
|
||||
// panel apparently unchanged when an adapter sees only the board's address.
|
||||
// This is intentionally not a feed alert because ambient unresolved devices
|
||||
// can appear during commissioning and the state is already visible locally.
|
||||
updateStatus(
|
||||
'identifying',
|
||||
message.error || 'Bluetooth device detected; checking whether it is the Balance Board.',
|
||||
);
|
||||
} else if (workerState === 'pairing') {
|
||||
updateStatus('pairing', 'Board found. Pairing now.');
|
||||
} else if (workerState === 'connected') {
|
||||
connected = true;
|
||||
updateStatus('connected', 'Connected. Waiting for live weight data.');
|
||||
} else if (workerState === 'link-detected') {
|
||||
connected = false;
|
||||
// The native bridge can now distinguish which half of the board's HID
|
||||
// connection reached the server. Preserve that diagnostic until both
|
||||
// channels arrive; the generic text remains for the outbound Sync flow.
|
||||
updateStatus('connecting', message.error || 'Board responded. Reading its sensor calibration.');
|
||||
} else if (workerState === 'connection-failed') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||
updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
|
||||
} else if (workerState === 'sleeping') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Board slept during calibration.');
|
||||
updateStatus('sleeping', message.error || 'Board is asleep. Press the front power button to wake it.');
|
||||
} else if (workerState === 'waiting') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
|
||||
} else if (workerState === 'error') {
|
||||
connected = false;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Worker stopped during calibration.');
|
||||
updateStatus('error', message.error || 'The Balance Board worker stopped.');
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
|
||||
socket.join(FRAME_ROOM);
|
||||
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
|
||||
cb({ success: true });
|
||||
});
|
||||
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
|
||||
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
startZeroCalibration();
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message || 'Failed to start Balance Board zero calibration' });
|
||||
}
|
||||
});
|
||||
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resetWeightRecord();
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to reset Balance Board weight record', err);
|
||||
cb({ error: err.message || 'Failed to reset the Balance Board weight record' });
|
||||
}
|
||||
});
|
||||
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
if (unpairing) {
|
||||
cb({ error: 'The Balance Board is already being unpaired' });
|
||||
return;
|
||||
}
|
||||
|
||||
unpairing = true;
|
||||
const address = store.address;
|
||||
let bluetoothWarning = '';
|
||||
try {
|
||||
if (address) {
|
||||
try {
|
||||
// A complete forget removes both sources of remembered identity. If
|
||||
// only the JSON address or only the BlueZ bond were removed, the next
|
||||
// red-Sync attempt could inherit half of the previous relationship.
|
||||
await execFileAsync('bluetoothctl', ['remove', address], { timeout: 10000 });
|
||||
} catch (err) {
|
||||
bluetoothWarning = String(
|
||||
err?.stderr || err?.message || 'BlueZ did not remove the bond',
|
||||
).trim();
|
||||
logger.warn('Balance Board BlueZ bond removal failed', bluetoothWarning);
|
||||
}
|
||||
}
|
||||
|
||||
store.address = '';
|
||||
store.zeroCorners = emptyZeroCorners();
|
||||
store.zeroedAt = null;
|
||||
clearRecordPersistTimer();
|
||||
store.recordKg = 0;
|
||||
store.recordedAt = null;
|
||||
persistStore();
|
||||
clearZeroTimer();
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
connected = false;
|
||||
batteryPercent = null;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
previousWorkerState = '';
|
||||
hardware?.setAddress('');
|
||||
hardware?.restart();
|
||||
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||
sendRawAlert('unpaired');
|
||||
cb({ success: true, warning: bluetoothWarning || null });
|
||||
} catch (err) {
|
||||
logger.error('Failed to unpair Balance Board', err);
|
||||
cb({ error: err.message || 'Failed to unpair the Balance Board' });
|
||||
} finally {
|
||||
unpairing = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
hardware = createBalanceBoardHardware({
|
||||
logger,
|
||||
address: store.address,
|
||||
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
|
||||
});
|
||||
hardware.events.on('message', handleWorkerMessage);
|
||||
hardware.start();
|
||||
} else {
|
||||
logger.info('Balance Board disabled by config');
|
||||
}
|
||||
|
||||
function installShutdownHooks() {
|
||||
const shutdown = () => {
|
||||
clearZeroTimer();
|
||||
// A record may still be inside the short debounce window when the process
|
||||
// receives a normal shutdown signal. Flush that newest maximum before the
|
||||
// hardware worker stops so a clean restart cannot lose it.
|
||||
if (recordPersistTimer) {
|
||||
clearRecordPersistTimer();
|
||||
persistStore();
|
||||
}
|
||||
hardware?.stop();
|
||||
};
|
||||
process.once('exit', shutdown);
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
installShutdownHooks();
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
balanceBoardEvents: events,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
CXX ?= g++
|
||||
|
||||
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
|
||||
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
|
||||
# profile: current BlueZ requests medium link security for a bonded board, and
|
||||
# the original Balance Board rejects that negotiation before an input device is
|
||||
# created.
|
||||
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||
LDLIBS += -lwiiuse -lbluetooth -pthread
|
||||
|
||||
TARGET := balance_board_worker
|
||||
SRC := balance_board_worker.cpp
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRC)
|
||||
$(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user