mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
248
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9d8e22ad1e | ||
|
|
385e7c25fa | ||
|
|
3a8a2ebb13 | ||
|
|
96d06091ee | ||
|
|
15e03e62ed | ||
|
|
3aa97baa4f | ||
|
|
017b3c69d5 | ||
|
|
ad7de34d6d | ||
|
|
51fbee400c | ||
|
|
7fe5730953 | ||
|
|
0d6b4d68de | ||
|
|
1c401ff90a | ||
|
|
f6b9fa798e | ||
|
|
f667bbce53 | ||
|
|
f480e01bf7 | ||
|
|
e2e94da656 | ||
|
|
8ee680ce9f | ||
|
|
be37a39291 | ||
|
|
af484f5099 | ||
|
|
5d8cb48fd0 | ||
|
|
c742fa1c81 | ||
|
|
6dc067580d | ||
|
|
d9e6317220 | ||
|
|
f969e50772 | ||
|
|
7d3f702e32 | ||
|
|
5f3206a065 | ||
|
|
411313b21b | ||
|
|
fa92726e9c | ||
|
|
30b8867b3a | ||
|
|
e254eea9e4 | ||
|
|
60eacf982c | ||
|
|
23108241f1 | ||
|
|
cc525afe20 | ||
|
|
a5884d9eac | ||
|
|
0fc4973cb7 | ||
|
|
7dfdf62c94 | ||
|
|
7286c5b36c | ||
|
|
4360e9ca03 | ||
|
|
dd0ba60d49 | ||
|
|
b5cb9bc8e4 | ||
|
|
fd1b3e103d | ||
|
|
9732f6c080 | ||
|
|
2064c4196c | ||
|
|
2cef95e6e3 | ||
|
|
76318e6b36 | ||
|
|
09ce66e7a4 | ||
|
|
fd1caf2df9 | ||
|
|
58410cd66b | ||
|
|
6d4000bed7 | ||
|
|
6e63f0e19c | ||
|
|
ed24d9ea89 | ||
|
|
b3141e9870 | ||
|
|
64d6d5a601 | ||
|
|
db44d23947 | ||
|
|
924a3c3d55 | ||
|
|
4d66defae0 | ||
|
|
0bb3f89472 | ||
|
|
e4ada54cf4 | ||
|
|
b393c2b2b4 | ||
|
|
18649deeae | ||
|
|
6747658106 | ||
|
|
033a2bae43 | ||
|
|
27dbb068be | ||
|
|
71706fb1b9 | ||
|
|
65b54f01d2 | ||
|
|
026e9de476 | ||
|
|
91bbeb6d8a | ||
|
|
43e4527ad5 | ||
|
|
a07d532043 | ||
|
|
2bd6215be2 | ||
|
|
e02b7a2eb7 | ||
|
|
10a586e5d0 | ||
|
|
775dd7b830 | ||
|
|
f2d3567978 | ||
|
|
10121650de | ||
|
|
9b875aedcb | ||
|
|
6d685c26ba | ||
|
|
dd8e87fda7 | ||
|
|
bf3ce9a28a | ||
|
|
0c0b55fe88 | ||
|
|
c06ac6bc20 | ||
|
|
8ec9ecc8d4 | ||
|
|
02599a44e4 | ||
|
|
4b8aa67c32 | ||
|
|
0e5f76fb2f | ||
|
|
3af74870a5 | ||
|
|
aee1a9d563 | ||
|
|
b3001cf0a3 | ||
|
|
d777e3a48e | ||
|
|
ee393adc8e | ||
|
|
c406ace339 | ||
|
|
6a31d8bc35 | ||
|
|
a6f6ccf079 | ||
|
|
5d60544904 | ||
|
|
3a40a65d46 | ||
|
|
f15ace85f6 | ||
|
|
656bf90e7f | ||
|
|
2d75935e65 | ||
|
|
42c248e298 | ||
|
|
55e8235b02 | ||
|
|
77de628c0b | ||
|
|
f31b21559c | ||
|
|
f7514e71cc | ||
|
|
f4683cd47d | ||
|
|
2beb1498fa | ||
|
|
d349df2432 | ||
|
|
11340bf3f6 | ||
|
|
e6c4931210 | ||
|
|
8f0ac358d6 | ||
|
|
4204a66549 | ||
|
|
a8bff428c2 | ||
|
|
69b49ae1d6 | ||
|
|
07ad43f42f | ||
|
|
f9f87c00d3 | ||
|
|
6f6325f477 | ||
|
|
0b3c7869af | ||
|
|
b526beb712 | ||
|
|
df22ac6d81 | ||
|
|
6c06275c6d | ||
|
|
3aea6d4766 | ||
|
|
3e632ac607 | ||
|
|
d77ec54bc9 | ||
|
|
40e8adf15a | ||
|
|
29bf4cc5d2 | ||
|
|
b083938338 | ||
|
|
5cade7a941 | ||
|
|
00277667d6 | ||
|
|
83a6910c25 | ||
|
|
295d01f7cc | ||
|
|
d8e63bdf2e | ||
|
|
fa4852b93c |
+8
-1
@@ -5,6 +5,8 @@ create_2_Open_Interface_Spec.txt
|
||||
|
||||
logs
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pio
|
||||
.vscode/
|
||||
config.h
|
||||
@@ -19,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
|
||||
|
||||
@@ -4,6 +4,8 @@ A system for controlling create 2 compatible roombas through a webpage.
|
||||
You can explore my basement through this project here:
|
||||
https://rover.otter.land
|
||||
|
||||
*some of this code was created with help from large language models, and some of it was written by me. This project would not have been possible for me to create without it.*
|
||||
|
||||
## This guide is a work in progress, it will cover:
|
||||
- Building rovers
|
||||
- Installing roverd on a rover's raspberry pi
|
||||
|
||||
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,11 @@
|
||||
{
|
||||
"name": "RoverPeripheralFirmata",
|
||||
"version": "0.1.0",
|
||||
"description": "Self-describing Firmata controls for MultiRoombaRover ESP32 peripherals",
|
||||
"frameworks": "arduino",
|
||||
"platforms": "espressif32",
|
||||
"dependencies": {
|
||||
"bblanchon/ArduinoJson": "^7.4.2",
|
||||
"madhephaestus/ESP32Servo": "^3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
#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::validateControlIdentity(const String& id, const String& name) const {
|
||||
if (id.length() == 0 || 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 == id) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::validateRange(const String& id, int minimum, int maximum) const {
|
||||
if (id.length() == 0 || minimum > maximum) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, const FirmataServoOutput& output) {
|
||||
validateControlIdentity(config.id, config.name);
|
||||
validateRange(config.id, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.id;
|
||||
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 FirmataPwmOutput& output) {
|
||||
validateControlIdentity(config.id, config.name);
|
||||
validateRange(config.id, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.id;
|
||||
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 FirmataDigitalOutput& output) {
|
||||
validateControlIdentity(config.id, config.name);
|
||||
ControlRegistration control;
|
||||
control.id = config.id;
|
||||
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) {
|
||||
validateControlIdentity(config.id, config.name);
|
||||
validateRange(config.id, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.id;
|
||||
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) {
|
||||
validateControlIdentity(config.id, config.name);
|
||||
ControlRegistration control;
|
||||
control.id = config.id;
|
||||
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) {
|
||||
validateControlIdentity(config.id, config.name);
|
||||
validateRange(config.id, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.id;
|
||||
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) {
|
||||
validateControlIdentity(config.id, config.name);
|
||||
if (config.maximumLength == 0) {
|
||||
abort();
|
||||
}
|
||||
ControlRegistration control;
|
||||
control.id = config.id;
|
||||
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,170 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ConfigurableFirmata.h>
|
||||
#include <ESP32Servo.h>
|
||||
#include <FirmataExt.h>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
enum class OutputPolarity {
|
||||
ActiveHigh,
|
||||
ActiveLow,
|
||||
};
|
||||
|
||||
enum class ButtonMode {
|
||||
Toggle,
|
||||
Momentary,
|
||||
};
|
||||
|
||||
struct FirmataServoOutput {
|
||||
uint8_t pin = 0;
|
||||
};
|
||||
|
||||
struct FirmataPwmOutput {
|
||||
uint8_t pin = 0;
|
||||
};
|
||||
|
||||
struct FirmataDigitalOutput {
|
||||
uint8_t pin = 0;
|
||||
OutputPolarity polarity = OutputPolarity::ActiveHigh;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
struct RoverDigitalOutputConfig {
|
||||
uint8_t pin = 0;
|
||||
OutputPolarity polarity = OutputPolarity::ActiveHigh;
|
||||
bool initiallyOn = false;
|
||||
};
|
||||
|
||||
struct SliderControlConfig {
|
||||
String id;
|
||||
String name;
|
||||
int minimum = 0;
|
||||
int maximum = 100;
|
||||
};
|
||||
|
||||
struct ButtonControlConfig {
|
||||
String id;
|
||||
String name;
|
||||
ButtonMode mode = ButtonMode::Momentary;
|
||||
};
|
||||
|
||||
struct NumberControlConfig {
|
||||
String id;
|
||||
String name;
|
||||
int minimum = 0;
|
||||
int maximum = 100;
|
||||
};
|
||||
|
||||
struct TextControlConfig {
|
||||
String id;
|
||||
String name;
|
||||
size_t maximumLength = 32;
|
||||
};
|
||||
|
||||
using SliderCallback = std::function<void(int)>;
|
||||
using ButtonCallback = std::function<void(bool)>;
|
||||
using NumberCallback = std::function<void(int)>;
|
||||
using TextCallback = std::function<void(const String&)>;
|
||||
|
||||
/*
|
||||
* RoverPeripheralFirmata is both the sketch-facing registration API and one
|
||||
* ConfigurableFirmata feature. Keeping those responsibilities together gives a
|
||||
* peripheral author one object to configure while still allowing ordinary
|
||||
* Firmata tooling to use digital, PWM, and servo commands on the same stream.
|
||||
*/
|
||||
class RoverPeripheralFirmata : public FirmataFeature {
|
||||
public:
|
||||
explicit RoverPeripheralFirmata(const String& name);
|
||||
|
||||
void addServoSlider(const SliderControlConfig& config, const FirmataServoOutput& output);
|
||||
void addPwmSlider(const SliderControlConfig& config, const FirmataPwmOutput& output);
|
||||
void addDigitalButton(const ButtonControlConfig& config, const FirmataDigitalOutput& 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 validateControlIdentity(const String& id, const String& name) const;
|
||||
void validateRange(const String& id, 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,28 @@
|
||||
[platformio]
|
||||
default_envs = esp32dev
|
||||
|
||||
[env]
|
||||
platform = espressif32
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
lib_extra_dirs = ../libraries
|
||||
lib_deps =
|
||||
; 3.2.0 targets the Arduino 2.x core shipped by PlatformIO's stable ESP32
|
||||
; platform. ConfigurableFirmata 3.4.0 switched its bundled PWM source to the
|
||||
; Arduino 3.x LEDC API even when that unused source is compiled as a dependency.
|
||||
https://github.com/firmata/ConfigurableFirmata.git#3.2.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
madhephaestus/ESP32Servo@^3.0.8
|
||||
|
||||
; 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,122 @@
|
||||
#include <Arduino.h>
|
||||
#include <ConfigurableFirmata.h>
|
||||
#include <FirmataExt.h>
|
||||
#include <RoverPeripheralFirmata.h>
|
||||
|
||||
FirmataExt firmataExtension;
|
||||
RoverPeripheralFirmata peripheral("Rover GPIO");
|
||||
|
||||
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;
|
||||
|
||||
void runSpecialAction() {
|
||||
// This intentionally represents arbitrary device behavior rather than a raw
|
||||
// pin mapping. Replace it with a motor sequence, LED animation, actuator
|
||||
// routine, or any other application-specific function the accessory needs.
|
||||
digitalWrite(kSpecialActionPin, HIGH);
|
||||
delay(80);
|
||||
digitalWrite(kSpecialActionPin, LOW);
|
||||
}
|
||||
|
||||
void registerBuiltInRoverControls() {
|
||||
// These three roles replace physical GPIO backends while preserving the
|
||||
// existing camera, headlight, and laser commands and HUD controls.
|
||||
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.addRoverCameraServo(cameraServo);
|
||||
|
||||
RoverDigitalOutputConfig headlight;
|
||||
headlight.pin = 18;
|
||||
headlight.polarity = OutputPolarity::ActiveHigh;
|
||||
headlight.initiallyOn = false;
|
||||
peripheral.addRoverHeadlight(headlight);
|
||||
|
||||
RoverDigitalOutputConfig laser;
|
||||
laser.pin = 16;
|
||||
laser.polarity = OutputPolarity::ActiveHigh;
|
||||
laser.initiallyOn = false;
|
||||
peripheral.addRoverLaser(laser);
|
||||
}
|
||||
|
||||
void registerGenericControls() {
|
||||
// Registration order is UI order. This servo slider is handled entirely by
|
||||
// standard Firmata SET_PIN_MODE and EXTENDED_ANALOG messages from roverd.
|
||||
SliderControlConfig servoPosition;
|
||||
servoPosition.id = "servoPosition";
|
||||
servoPosition.name = "Servo position";
|
||||
servoPosition.minimum = 0;
|
||||
servoPosition.maximum = 180;
|
||||
|
||||
FirmataServoOutput servoOutput;
|
||||
servoOutput.pin = 13;
|
||||
peripheral.addServoSlider(servoPosition, servoOutput);
|
||||
|
||||
// PWM brightness is another standard Firmata output. No sketch callback is
|
||||
// involved when the driver moves this slider.
|
||||
SliderControlConfig lightBrightness;
|
||||
lightBrightness.id = "lightBrightness";
|
||||
lightBrightness.name = "Light brightness";
|
||||
lightBrightness.minimum = 0;
|
||||
lightBrightness.maximum = 255;
|
||||
|
||||
FirmataPwmOutput lightOutput;
|
||||
lightOutput.pin = 17;
|
||||
peripheral.addPwmSlider(lightBrightness, lightOutput);
|
||||
|
||||
// A custom momentary control receives both press and release. This example
|
||||
// runs a one-shot action only on press, but a motor could use both values to
|
||||
// start while held and stop on release.
|
||||
ButtonControlConfig specialAction;
|
||||
specialAction.id = "specialAction";
|
||||
specialAction.name = "Run special action";
|
||||
specialAction.mode = ButtonMode::Momentary;
|
||||
peripheral.addButton(specialAction, [](bool pressed) {
|
||||
if (pressed) {
|
||||
runSpecialAction();
|
||||
}
|
||||
});
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void setup() {
|
||||
pinMode(kSpecialActionPin, OUTPUT);
|
||||
digitalWrite(kSpecialActionPin, LOW);
|
||||
|
||||
registerBuiltInRoverControls();
|
||||
registerGenericControls();
|
||||
|
||||
// Supplying Serial as a Stream keeps all protocol code identical between a
|
||||
// CH340/CP210x UART bridge and native ESP32-S3 USB CDC. Only PlatformIO's S3
|
||||
// build flags differ.
|
||||
Serial.begin(115200);
|
||||
Firmata.begin(Serial);
|
||||
peripheral.begin(firmataExtension);
|
||||
|
||||
// A Firmata system reset establishes declared initial output states and also
|
||||
// proves that all callbacks were installed before normal traffic begins.
|
||||
Firmata.parse(SYSTEM_RESET);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Processing one complete parser unit at a time prevents a long serial burst
|
||||
// from starving application work while still draining ordinary USB traffic
|
||||
// quickly on both supported transports.
|
||||
while (Firmata.available()) {
|
||||
Firmata.processInput();
|
||||
if (!Firmata.isParsingMessage()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
peripheral.update();
|
||||
}
|
||||
@@ -1,88 +1,102 @@
|
||||
# ALSA routing for the Debian laptop rover profile.
|
||||
# Reference ALSA routing for the Debian laptop rover profile.
|
||||
#
|
||||
# This file intentionally mirrors the logical device names used by the Pi rover
|
||||
# audio setup. roverd can keep sending horn audio to "horn", forwarded browser
|
||||
# audio to "forward", and TTS to ALSA's default playback path without caring
|
||||
# which physical sound card is underneath the profile.
|
||||
# This intentionally mirrors pi/asound.conf as closely as a normal PC can:
|
||||
# one fixed hardware card, one dmix playback engine, separate softvol controls
|
||||
# for TTS/horn/forwarded audio, and a raw capture alias for the rover mic.
|
||||
#
|
||||
# The Debian laptop installer no longer copies this file directly. It renders
|
||||
# /etc/asound.conf from /etc/roverd-installer.env so laptops with HDMI as card 0
|
||||
# can point these same logical mixer devices at their real speaker card.
|
||||
#
|
||||
# This is NOT meant to preserve normal desktop audio behavior. The laptop rover
|
||||
# installer disables PipeWire/PulseAudio so roverd owns the audio hardware like
|
||||
# the Raspberry Pi rover does. If the laptop's real speaker/mic card is not ALSA
|
||||
# card 0, rerun the Debian laptop installer and answer the ALSA prompts using:
|
||||
# aplay -l
|
||||
# arecord -l
|
||||
|
||||
pcm.roverd_playback {
|
||||
type plug
|
||||
|
||||
# Use the system's first normal ALSA playback device as the physical sink.
|
||||
# This avoids referencing "default" here, because this file replaces
|
||||
# pcm.!default below and using it as a slave would recurse.
|
||||
slave.pcm "sysdefault"
|
||||
}
|
||||
|
||||
pcm.roverd_capture {
|
||||
type plug
|
||||
|
||||
# The media publisher records from "default"; with pcm.!default below that
|
||||
# capture side resolves here. Keeping capture separate from playback lets the
|
||||
# asym default expose ordinary microphone input while playback goes through
|
||||
# the TTS softvol path.
|
||||
slave.pcm "sysdefault"
|
||||
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||
pcm.dmixer {
|
||||
type dmix
|
||||
ipc_key 1024
|
||||
ipc_perm 0666
|
||||
slave {
|
||||
pcm "hw:0,0"
|
||||
format S16_LE
|
||||
rate 16000
|
||||
channels 1
|
||||
period_time 0
|
||||
period_size 1024
|
||||
buffer_size 4096
|
||||
}
|
||||
}
|
||||
|
||||
# TTS volume control (used by default playback path).
|
||||
pcm.tts_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Horn volume control.
|
||||
pcm.horn_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Forwarded audio volume control.
|
||||
pcm.forward_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Per-source playback PCMs.
|
||||
pcm.tts {
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
}
|
||||
|
||||
pcm.horn {
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
}
|
||||
|
||||
pcm.forward {
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
}
|
||||
|
||||
pcm.!default {
|
||||
type asym
|
||||
# Capture alias used by laptop rover config defaults.
|
||||
pcm.rovermic {
|
||||
type plug
|
||||
slave.pcm "hw:0,0"
|
||||
}
|
||||
|
||||
# Existing TTS engines play to their default ALSA output, so default playback
|
||||
# is intentionally the TTS path. This preserves the current TTS execution
|
||||
# model while still making the TTS volume control meaningful on laptops.
|
||||
playback.pcm "tts"
|
||||
capture.pcm "roverd_capture"
|
||||
# Defaults: TTS direct playback + raw capture on the dedicated laptop sound card.
|
||||
pcm.!default {
|
||||
type asym
|
||||
playback.pcm "tts"
|
||||
capture.pcm "rovermic"
|
||||
}
|
||||
|
||||
ctl.!default {
|
||||
type hw
|
||||
card 0
|
||||
type hw
|
||||
card 0
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
ASSET_ROOT = "/opt/roverd/googletts"
|
||||
LIB_PATH = os.path.join(ASSET_ROOT, "libchrometts.so")
|
||||
VOICE_DIR = os.path.join(ASSET_ROOT, "en-us-x-multi-r30")
|
||||
PIPELINE = "pipeline.pb"
|
||||
PLAYBACK_DEVICE = "tts"
|
||||
SAMPLE_RATE = "24000"
|
||||
MAX_TEXT_CHARS = 512
|
||||
|
||||
VOICES = {
|
||||
"sfg": "female",
|
||||
"iob": "female",
|
||||
"iog": "female",
|
||||
"iol": "male",
|
||||
"iom": "male",
|
||||
"tpc": "female",
|
||||
"tpd": "male",
|
||||
"tpf": "female",
|
||||
}
|
||||
DEFAULT_VOICE = "tpf"
|
||||
DEFAULT_PITCH = 1.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
MIN_PITCH = 0.5
|
||||
MAX_PITCH = 2.0
|
||||
MIN_SPEED = 0.5
|
||||
MAX_SPEED = 2.0
|
||||
|
||||
_runtime_handles = []
|
||||
|
||||
|
||||
def load_shared_library(path):
|
||||
mode = ctypes.RTLD_GLOBAL | getattr(os, "RTLD_NOW", 0)
|
||||
return ctypes.CDLL(path, mode=mode)
|
||||
|
||||
|
||||
def preload_runtime_libraries():
|
||||
# Laptop-only workaround: some ChromeOS libchrometts builds reference
|
||||
# compiler helper symbols such as __udivmodti4 without declaring the runtime
|
||||
# library as an ELF dependency. Loading common compiler runtimes globally
|
||||
# first makes those symbols visible before ctypes loads libchrometts.so.
|
||||
for name in ("gcc_s", "atomic", "stdc++", "c++", "c++abi"):
|
||||
lib = ctypes.util.find_library(name)
|
||||
if not lib:
|
||||
continue
|
||||
try:
|
||||
_runtime_handles.append(load_shared_library(lib))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
preload_runtime_libraries()
|
||||
|
||||
|
||||
def varint(value):
|
||||
out = bytearray()
|
||||
while value >= 0x80:
|
||||
out.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
out.append(value)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def field_bytes(number, payload):
|
||||
return varint((number << 3) | 2) + varint(len(payload)) + payload
|
||||
|
||||
|
||||
def field_float(number, value):
|
||||
return varint((number << 3) | 5) + struct.pack("<f", float(value))
|
||||
|
||||
|
||||
def build_utterance(text, pitch=1.0, speed=1.0):
|
||||
params = field_float(2, pitch) + field_float(3, speed)
|
||||
msg_b = field_bytes(1, text.encode("utf-8")) + field_bytes(20, params)
|
||||
msg_a = field_bytes(1, msg_b)
|
||||
return field_bytes(1, msg_a)
|
||||
|
||||
|
||||
def build_speaker(name, gender):
|
||||
return field_bytes(1, name.encode("utf-8")) + field_bytes(2, gender.encode("utf-8"))
|
||||
|
||||
|
||||
class ChromeTTS:
|
||||
def __init__(self):
|
||||
self.lib = load_shared_library(LIB_PATH)
|
||||
self.lib.GoogleTtsInit.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
self.lib.GoogleTtsInit.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsInitBuffered.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.GoogleTtsInitBuffered.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.argtypes = []
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.restype = ctypes.c_size_t
|
||||
self.lib.GoogleTtsReadBuffered.argtypes = [
|
||||
ctypes.POINTER(ctypes.c_float),
|
||||
ctypes.POINTER(ctypes.c_size_t),
|
||||
]
|
||||
self.lib.GoogleTtsReadBuffered.restype = ctypes.c_int
|
||||
self.lib.GoogleTtsShutdown.argtypes = []
|
||||
self.lib.GoogleTtsShutdown.restype = None
|
||||
|
||||
voice_dir = os.path.abspath(VOICE_DIR) + os.sep
|
||||
pipeline = os.path.join(voice_dir, PIPELINE)
|
||||
if not self.lib.GoogleTtsInit(pipeline.encode("utf-8"), voice_dir.encode("utf-8")):
|
||||
raise RuntimeError("GoogleTtsInit failed")
|
||||
self.frames = int(self.lib.GoogleTtsGetFramesInAudioBuffer())
|
||||
if self.frames <= 0:
|
||||
raise RuntimeError("invalid Google TTS audio buffer size")
|
||||
self.buffer = (ctypes.c_float * self.frames)()
|
||||
|
||||
def speak_to_aplay(self, text, voice, pitch=DEFAULT_PITCH, speed=DEFAULT_SPEED):
|
||||
voice = voice if voice in VOICES else DEFAULT_VOICE
|
||||
pitch = clamp_float(pitch, MIN_PITCH, MAX_PITCH, DEFAULT_PITCH)
|
||||
speed = clamp_float(speed, MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("text required")
|
||||
text = text[:MAX_TEXT_CHARS]
|
||||
utterance = build_utterance(text, pitch=pitch, speed=speed)
|
||||
speaker = build_speaker(voice, VOICES[voice])
|
||||
if not self.lib.GoogleTtsInitBuffered(utterance, speaker, len(utterance), len(speaker)):
|
||||
raise RuntimeError("GoogleTtsInitBuffered failed")
|
||||
|
||||
player = subprocess.Popen(
|
||||
["aplay", "-q", "-D", PLAYBACK_DEVICE, "-r", SAMPLE_RATE, "-f", "FLOAT_LE", "-c", "1"],
|
||||
stdin=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
frames_written = ctypes.c_size_t(0)
|
||||
while self.lib.GoogleTtsReadBuffered(self.buffer, ctypes.byref(frames_written)) > 0:
|
||||
frames = int(frames_written.value)
|
||||
if frames > 0:
|
||||
player.stdin.write(ctypes.string_at(self.buffer, frames * ctypes.sizeof(ctypes.c_float)))
|
||||
player.stdin.close()
|
||||
rc = player.wait()
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"aplay exited with {rc}")
|
||||
finally:
|
||||
if player.poll() is None:
|
||||
player.kill()
|
||||
player.wait()
|
||||
|
||||
def shutdown(self):
|
||||
self.lib.GoogleTtsShutdown()
|
||||
|
||||
|
||||
def respond(payload):
|
||||
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def clamp_float(value, minimum, maximum, fallback):
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if value <= 0:
|
||||
return fallback
|
||||
if value < minimum:
|
||||
return minimum
|
||||
if value > maximum:
|
||||
return maximum
|
||||
return value
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
tts = ChromeTTS()
|
||||
except Exception as exc:
|
||||
respond({"ok": False, "error": str(exc)})
|
||||
return 1
|
||||
|
||||
respond({"ok": True, "ready": True})
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
tts.speak_to_aplay(
|
||||
str(request.get("text") or ""),
|
||||
str(request.get("voice") or DEFAULT_VOICE),
|
||||
request.get("pitch", DEFAULT_PITCH),
|
||||
request.get("speed", DEFAULT_SPEED),
|
||||
)
|
||||
respond({"ok": True})
|
||||
except Exception as exc:
|
||||
respond({"ok": False, "error": str(exc)})
|
||||
finally:
|
||||
tts.shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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"
|
||||
@@ -29,6 +29,15 @@ if [[ "${EUID}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${ROVERD_SELF_UPDATE_SYSTEMD:-}" != "1" ]] && command -v systemd-run >/dev/null 2>&1; then
|
||||
exec systemd-run \
|
||||
--unit=roverd-self-update \
|
||||
--collect \
|
||||
--property=Type=exec \
|
||||
--setenv=ROVERD_SELF_UPDATE_SYSTEMD=1 \
|
||||
"$0"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2
|
||||
exit 1
|
||||
@@ -86,3 +95,4 @@ log "Repository fast-forward pull complete"
|
||||
# drift away from the normal manual install path.
|
||||
"$ROVERD_REPO_DIR/pi/install_roverd.sh"
|
||||
log "Installer completed successfully"
|
||||
systemctl reboot
|
||||
|
||||
@@ -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 \
|
||||
@@ -120,6 +124,7 @@ run_pipeline() {
|
||||
--framerate "${ROVERD_VIDEO_FPS}" \
|
||||
--bitrate "${ROVERD_VIDEO_BITRATE}" \
|
||||
--codec h264 \
|
||||
--intra 120 \
|
||||
--profile baseline \
|
||||
--denoise auto \
|
||||
--nopreview \
|
||||
@@ -141,7 +146,8 @@ run_pipeline() {
|
||||
-flush_packets 1 \
|
||||
-muxdelay 0 \
|
||||
-muxpreload 0 \
|
||||
-f mpegts \
|
||||
-f rtsp \
|
||||
-rtsp_transport tcp \
|
||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,425 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
install_debian_laptop_deps() {
|
||||
# This profile is deliberately Debian-only. Using apt directly is simpler
|
||||
# than adding a fake cross-distro layer, and it keeps the installed package
|
||||
# set easy to inspect on the actual rover laptop.
|
||||
# The laptop rover is a dedicated appliance, not a normal desktop/laptop audio
|
||||
# install. Keep this package set intentionally close to the Pi profile so the
|
||||
# same roverd TTS/playback/capture code paths are available on both targets.
|
||||
if command -v ffmpeg >/dev/null 2>&1 \
|
||||
&& command -v arecord >/dev/null 2>&1 \
|
||||
&& command -v aplay >/dev/null 2>&1 \
|
||||
&& command -v amixer >/dev/null 2>&1 \
|
||||
&& command -v v4l2-ctl >/dev/null 2>&1 \
|
||||
&& command -v flite >/dev/null 2>&1 \
|
||||
&& command -v espeak >/dev/null 2>&1; then
|
||||
log "Debian laptop media/audio dependencies already installed; skipping apt install"
|
||||
&& command -v espeak >/dev/null 2>&1 \
|
||||
&& command -v python3 >/dev/null 2>&1 \
|
||||
&& command -v curl >/dev/null 2>&1 \
|
||||
&& command -v xz >/dev/null 2>&1 \
|
||||
&& command -v unzip >/dev/null 2>&1 \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libgcc_s\.so\.1' \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libstdc\+\+\.so\.6' \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libc++\.so\.1' \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libc++abi\.so\.1'; then
|
||||
log "Debian laptop media/audio/TTS dependencies already installed; skipping apt install"
|
||||
return
|
||||
fi
|
||||
|
||||
log "Installing Debian laptop dependencies (ffmpeg, ALSA tools, V4L2 tools, flite, espeak)..."
|
||||
log "Installing Debian laptop rover dependencies (ffmpeg, ALSA tools, V4L2 tools, flite/espeak, Chrome TTS runtime deps)..."
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ffmpeg alsa-utils v4l-utils ca-certificates flite espeak
|
||||
apt-get install -y --no-install-recommends \
|
||||
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1 libc++abi1 \
|
||||
|| apt-get install -y --no-install-recommends \
|
||||
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1-14 libc++abi1-14
|
||||
}
|
||||
|
||||
DEBIAN_LAPTOP_INSTALLER_CONFIG="/etc/roverd-installer.env"
|
||||
|
||||
disable_debian_laptop_desktop_audio_stack() {
|
||||
# This profile is for a dedicated rover laptop. PipeWire/PulseAudio are good
|
||||
# desktop defaults, but they can grab the hardware device and make the rover's
|
||||
# root/systemd ALSA services fail or route through a moving per-user graph.
|
||||
# Mask them globally and kill already-running instances so ALSA owns the box,
|
||||
# which is the closest behavior to the Pi rover appliance setup.
|
||||
log "Disabling desktop audio daemons for dedicated laptop rover audio"
|
||||
|
||||
local -a user_units=(
|
||||
pipewire.service
|
||||
pipewire.socket
|
||||
pipewire-pulse.service
|
||||
pipewire-pulse.socket
|
||||
wireplumber.service
|
||||
pulseaudio.service
|
||||
pulseaudio.socket
|
||||
)
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl --global disable "${user_units[@]}" >/dev/null 2>&1 || true
|
||||
systemctl --global mask "${user_units[@]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
pkill -x pipewire >/dev/null 2>&1 || true
|
||||
pkill -x pipewire-pulse >/dev/null 2>&1 || true
|
||||
pkill -x wireplumber >/dev/null 2>&1 || true
|
||||
pkill -x pulseaudio >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
derive_debian_laptop_alsa_card_from_device() {
|
||||
local device="$1"
|
||||
|
||||
# The common ALSA hardware device shape is hw:CARD,DEVICE. Pulling the card
|
||||
# number from that string gives the installer a useful default while still
|
||||
# allowing the prompt to handle named cards or uncommon PCM strings.
|
||||
if [[ "$device" =~ ^hw:([0-9]+),[0-9]+$ ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[1]}"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '0\n'
|
||||
}
|
||||
|
||||
read_debian_laptop_installer_value() {
|
||||
local prompt="$1"
|
||||
local default_value="$2"
|
||||
local value=""
|
||||
|
||||
# Prompting through /dev/tty keeps this usable even when the installer is
|
||||
# launched through sudo with stdin redirected. The caller already checks for
|
||||
# an interactive terminal before reaching this function, so failure here is
|
||||
# genuinely unexpected and should stop the install instead of guessing.
|
||||
read -r -p "${prompt} [${default_value}]: " value </dev/tty
|
||||
if [[ -z "$value" ]]; then
|
||||
value="$default_value"
|
||||
fi
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
validate_debian_laptop_alsa_config() {
|
||||
# The PCM fields are written inside quoted ALSA strings, so keep them to the
|
||||
# device spellings ALSA normally uses for hardware/plugin PCMs. Rejecting
|
||||
# whitespace and shell/config punctuation prevents a bad installer config
|
||||
# from generating an asound.conf that changes structure instead of values.
|
||||
if [[ ! "$ROVERD_ALSA_PLAYBACK_DEVICE" =~ ^[A-Za-z0-9_.,:+/-]+$ ]]; then
|
||||
echo "Invalid ROVERD_ALSA_PLAYBACK_DEVICE: $ROVERD_ALSA_PLAYBACK_DEVICE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$ROVERD_ALSA_CAPTURE_DEVICE" =~ ^[A-Za-z0-9_.,:+/-]+$ ]]; then
|
||||
echo "Invalid ROVERD_ALSA_CAPTURE_DEVICE: $ROVERD_ALSA_CAPTURE_DEVICE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Softvol controls and ctl.!default need the playback card, because
|
||||
# TTSMaster, HornMaster, and ForwardMaster are all playback mixer controls.
|
||||
# Keep this numeric to match the prompt and avoid needing quoted ALSA card
|
||||
# ids in the generated config.
|
||||
if [[ ! "$ROVERD_ALSA_PLAYBACK_CARD" =~ ^[0-9]+$ ]]; then
|
||||
echo "Invalid ROVERD_ALSA_PLAYBACK_CARD: $ROVERD_ALSA_PLAYBACK_CARD" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
load_debian_laptop_installer_config_file() {
|
||||
local config_path="$1"
|
||||
local line key val
|
||||
|
||||
# Read only the small allowlist this installer owns. Avoid sourcing the file
|
||||
# because it lives in /etc and is meant to be installer data, not shell code.
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
|
||||
val="${val#${val%%[![:space:]]*}}"
|
||||
val="${val%${val##*[![:space:]]}}"
|
||||
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
ROVERD_ALSA_PLAYBACK_DEVICE|ROVERD_ALSA_PLAYBACK_CARD|ROVERD_ALSA_CAPTURE_DEVICE)
|
||||
printf -v "$key" '%s' "$val"
|
||||
export "$key"
|
||||
;;
|
||||
esac
|
||||
done < "$config_path"
|
||||
}
|
||||
|
||||
write_debian_laptop_installer_config_file() {
|
||||
local config_path="$1"
|
||||
local tmp_path
|
||||
|
||||
tmp_path="$(mktemp)"
|
||||
# This file is intentionally plain KEY=VALUE shell-style data so future
|
||||
# installs can reuse the same laptop-specific card choices without asking
|
||||
# again. It is still parsed by an allowlist reader instead of sourced.
|
||||
cat > "$tmp_path" <<EOF
|
||||
# Created by install_roverd.sh for the Debian laptop rover profile.
|
||||
# These values choose the physical ALSA hardware behind the rover's logical
|
||||
# mixer devices: tts, horn, forward, default playback, and rovermic capture.
|
||||
ROVERD_ALSA_PLAYBACK_DEVICE="${ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||
ROVERD_ALSA_PLAYBACK_CARD="${ROVERD_ALSA_PLAYBACK_CARD}"
|
||||
ROVERD_ALSA_CAPTURE_DEVICE="${ROVERD_ALSA_CAPTURE_DEVICE}"
|
||||
EOF
|
||||
install -o root -g root -m 0644 "$tmp_path" "$config_path"
|
||||
rm -f "$tmp_path"
|
||||
}
|
||||
|
||||
load_or_create_debian_laptop_alsa_config() {
|
||||
if [[ -f "$DEBIAN_LAPTOP_INSTALLER_CONFIG" ]]; then
|
||||
load_debian_laptop_installer_config_file "$DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||
log "Using Debian laptop ALSA installer config from $DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||
elif [[ -n "${ROVERD_ALSA_PLAYBACK_DEVICE:-}" && -n "${ROVERD_ALSA_PLAYBACK_CARD:-}" && -n "${ROVERD_ALSA_CAPTURE_DEVICE:-}" ]]; then
|
||||
# This keeps unattended installs possible without adding a pile of CLI
|
||||
# flags. The generated /etc file still becomes the durable source for
|
||||
# future installs on the same laptop.
|
||||
validate_debian_laptop_alsa_config
|
||||
write_debian_laptop_installer_config_file "$DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||
log "Wrote Debian laptop ALSA installer config to $DEBIAN_LAPTOP_INSTALLER_CONFIG from environment"
|
||||
else
|
||||
if ! { true </dev/tty >/dev/tty; } 2>/dev/null; then
|
||||
echo "Missing $DEBIAN_LAPTOP_INSTALLER_CONFIG and no interactive terminal is available for ALSA setup." >&2
|
||||
echo "Run sudo ./pi/install_roverd.sh --debian-laptop once from a terminal, then reuse the generated config for future installs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "No $DEBIAN_LAPTOP_INSTALLER_CONFIG found; creating Debian laptop ALSA installer config"
|
||||
if command -v aplay >/dev/null 2>&1; then
|
||||
echo "Playback devices from aplay -l:" >/dev/tty
|
||||
aplay -l >/dev/tty 2>/dev/tty || true
|
||||
fi
|
||||
if command -v arecord >/dev/null 2>&1; then
|
||||
echo "Capture devices from arecord -l:" >/dev/tty
|
||||
arecord -l >/dev/tty 2>/dev/tty || true
|
||||
fi
|
||||
|
||||
ROVERD_ALSA_PLAYBACK_DEVICE="$(read_debian_laptop_installer_value "ALSA playback device for rover speaker output" "${ROVERD_ALSA_PLAYBACK_DEVICE:-hw:0,0}")"
|
||||
ROVERD_ALSA_PLAYBACK_CARD="$(read_debian_laptop_installer_value "ALSA playback card number for mixer controls" "${ROVERD_ALSA_PLAYBACK_CARD:-$(derive_debian_laptop_alsa_card_from_device "$ROVERD_ALSA_PLAYBACK_DEVICE")}")"
|
||||
ROVERD_ALSA_CAPTURE_DEVICE="$(read_debian_laptop_installer_value "ALSA capture device for rover microphone input" "${ROVERD_ALSA_CAPTURE_DEVICE:-$ROVERD_ALSA_PLAYBACK_DEVICE}")"
|
||||
|
||||
validate_debian_laptop_alsa_config
|
||||
write_debian_laptop_installer_config_file "$DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||
log "Wrote Debian laptop ALSA installer config to $DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||
fi
|
||||
|
||||
ROVERD_ALSA_PLAYBACK_DEVICE="${ROVERD_ALSA_PLAYBACK_DEVICE:-hw:0,0}"
|
||||
ROVERD_ALSA_PLAYBACK_CARD="${ROVERD_ALSA_PLAYBACK_CARD:-$(derive_debian_laptop_alsa_card_from_device "$ROVERD_ALSA_PLAYBACK_DEVICE")}"
|
||||
ROVERD_ALSA_CAPTURE_DEVICE="${ROVERD_ALSA_CAPTURE_DEVICE:-$ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||
validate_debian_laptop_alsa_config
|
||||
}
|
||||
|
||||
render_debian_laptop_asound_config() {
|
||||
local tmp_path
|
||||
|
||||
tmp_path="$(mktemp)"
|
||||
# The rover-facing ALSA names stay stable even when the laptop's physical
|
||||
# sound card changes. dmixer owns the one real playback PCM, while tts,
|
||||
# horn, and forward each wrap that mixer with a separate softvol control.
|
||||
cat > "$tmp_path" <<EOF
|
||||
# Dedicated ALSA routing for the Debian laptop rover profile.
|
||||
#
|
||||
# Generated by install_roverd.sh from $DEBIAN_LAPTOP_INSTALLER_CONFIG.
|
||||
# Change the physical devices there, then rerun the Debian laptop installer.
|
||||
#
|
||||
# Logical playback devices:
|
||||
# tts - default text-to-speech output with TTSMaster softvol
|
||||
# horn - horn synth output with HornMaster softvol
|
||||
# forward - browser-forwarded audio with ForwardMaster softvol
|
||||
# default - TTS playback plus rovermic capture
|
||||
#
|
||||
# Physical routing selected for this laptop:
|
||||
# playback PCM: ${ROVERD_ALSA_PLAYBACK_DEVICE}
|
||||
# playback card: ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||
# capture PCM: ${ROVERD_ALSA_CAPTURE_DEVICE}
|
||||
|
||||
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||
pcm.dmixer {
|
||||
type dmix
|
||||
ipc_key 1024
|
||||
ipc_perm 0666
|
||||
slave {
|
||||
pcm "${ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||
format S16_LE
|
||||
rate 16000
|
||||
channels 1
|
||||
period_time 0
|
||||
period_size 1024
|
||||
buffer_size 4096
|
||||
}
|
||||
}
|
||||
|
||||
# TTS volume control. TTS uses the default playback route, so this control lets
|
||||
# generated speech move independently from horns and forwarded browser audio.
|
||||
pcm.tts_softvol {
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Horn volume control. The horn synth opens the logical "horn" device, which
|
||||
# keeps horn loudness adjustable without changing the shared hardware PCM.
|
||||
pcm.horn_softvol {
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Forwarded audio volume control. The browser-audio listener opens "forward",
|
||||
# so remote audio can be mixed with local rover sounds without bypassing dmix.
|
||||
pcm.forward_softvol {
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Per-source playback PCMs.
|
||||
pcm.tts {
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
}
|
||||
|
||||
pcm.horn {
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
}
|
||||
|
||||
pcm.forward {
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
}
|
||||
|
||||
# Capture alias used by laptop rover config defaults. Capture is deliberately
|
||||
# separate from playback because laptop speakers and microphones often appear
|
||||
# on different ALSA cards.
|
||||
pcm.rovermic {
|
||||
type plug
|
||||
slave.pcm "${ROVERD_ALSA_CAPTURE_DEVICE}"
|
||||
}
|
||||
|
||||
# Defaults: TTS direct playback + raw capture on the selected laptop devices.
|
||||
pcm.!default {
|
||||
type asym
|
||||
playback.pcm "tts"
|
||||
capture.pcm "rovermic"
|
||||
}
|
||||
|
||||
ctl.!default {
|
||||
type hw
|
||||
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||
}
|
||||
EOF
|
||||
install -o root -g root -m 0644 "$tmp_path" /etc/asound.conf
|
||||
rm -f "$tmp_path"
|
||||
log "Installed dedicated Debian laptop ALSA config to /etc/asound.conf using playback ${ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||
}
|
||||
|
||||
install_debian_laptop_audio_support() {
|
||||
if [[ ! -f pi/asound.debian-laptop.conf ]]; then
|
||||
log "WARNING: pi/asound.debian-laptop.conf missing; skipping Debian laptop ALSA config install"
|
||||
load_or_create_debian_laptop_alsa_config
|
||||
render_debian_laptop_asound_config
|
||||
|
||||
install -D -o root -g root -m 0755 pi/bin/chromegtts-daemon-laptop.py /usr/local/bin/chromegtts-daemon
|
||||
log "Installed laptop chromegtts daemon"
|
||||
|
||||
install_google_tts_assets_laptop
|
||||
|
||||
log "ALSA config updated; reboot recommended before testing laptop rover audio"
|
||||
}
|
||||
|
||||
install_google_tts_assets_laptop() {
|
||||
local asset_dir="/opt/roverd/googletts"
|
||||
local voice_dir="${asset_dir}/en-us-x-multi-r30"
|
||||
local dist_url="https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-26.5.tar.xz"
|
||||
local tmp_dir
|
||||
local lib_member=""
|
||||
local member
|
||||
|
||||
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
|
||||
log "Google Chrome TTS assets already installed; skipping download"
|
||||
return
|
||||
fi
|
||||
|
||||
# The laptop profile still uses roverd's existing audio contract: TTS plays
|
||||
# to ALSA's default output, horn plays to the named "horn" device, and
|
||||
# forwarded web audio plays to the named "forward" device. Installing one
|
||||
# profile-specific asound.conf gives those paths independent softvol mixer
|
||||
# controls without changing the TTS runtime code.
|
||||
install -m 0644 pi/asound.debian-laptop.conf /etc/asound.conf
|
||||
log "Installed Debian laptop ALSA config to /etc/asound.conf"
|
||||
log "ALSA config updated; restarting audio clients or rebooting is recommended before testing laptop audio"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
log "Downloading Google Chrome TTS assets for Debian laptop profile..."
|
||||
if ! curl -L -o "${tmp_dir}/googletts-26.5.tar.xz" "$dist_url"; then
|
||||
rm -rf "$tmp_dir"
|
||||
log "WARNING: failed to download Google Chrome TTS assets; chromegtts will be unavailable"
|
||||
return
|
||||
fi
|
||||
|
||||
local -a candidate_libs=()
|
||||
case "$(uname -m)" in
|
||||
aarch64|arm64)
|
||||
candidate_libs=(libchrometts_arm64.so)
|
||||
;;
|
||||
armv7l|armhf)
|
||||
candidate_libs=(libchrometts_armv7.so)
|
||||
;;
|
||||
x86_64|amd64)
|
||||
candidate_libs=(libchrometts_x86_64.so libchrometts_amd64.so libchrometts_x64.so libchrometts.so)
|
||||
;;
|
||||
i386|i686)
|
||||
candidate_libs=(libchrometts_x86.so libchrometts_i386.so libchrometts.so)
|
||||
;;
|
||||
*)
|
||||
log "WARNING: unsupported Chrome TTS architecture $(uname -m); skipping Google TTS assets"
|
||||
rm -rf "$tmp_dir"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
for member in "${candidate_libs[@]}"; do
|
||||
if tar -tf "${tmp_dir}/googletts-26.5.tar.xz" "$member" >/dev/null 2>&1; then
|
||||
lib_member="$member"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$lib_member" ]]; then
|
||||
log "WARNING: no libchrometts library matching $(uname -m) found in Google TTS archive; chromegtts will be unavailable"
|
||||
rm -rf "$tmp_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! tar -xf "${tmp_dir}/googletts-26.5.tar.xz" -C "$tmp_dir" en-us-x-multi.zvoice "$lib_member"; then
|
||||
rm -rf "$tmp_dir"
|
||||
log "WARNING: failed to unpack Google Chrome TTS assets; chromegtts will be unavailable"
|
||||
return
|
||||
fi
|
||||
|
||||
install -d -o root -g root -m 0755 "$asset_dir"
|
||||
install -o root -g root -m 0644 "${tmp_dir}/${lib_member}" "${asset_dir}/libchrometts.so"
|
||||
rm -rf "$voice_dir"
|
||||
install -d -o root -g root -m 0755 "$voice_dir"
|
||||
unzip -q "${tmp_dir}/en-us-x-multi.zvoice" -d "$voice_dir"
|
||||
chown -R root:root "$asset_dir"
|
||||
find "$asset_dir" -type d -exec chmod 0755 {} +
|
||||
find "$asset_dir" -type f -exec chmod 0644 {} +
|
||||
rm -rf "$tmp_dir"
|
||||
log "Installed Google Chrome TTS assets to $asset_dir using $lib_member"
|
||||
}
|
||||
|
||||
install_debian_laptop_profile() {
|
||||
install_debian_laptop_deps
|
||||
disable_debian_laptop_desktop_audio_stack
|
||||
install_debian_laptop_audio_support
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+85
-72
@@ -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:"-"`
|
||||
@@ -177,17 +183,20 @@ type PrivateConfig struct {
|
||||
}
|
||||
|
||||
type PrivateSafetyConfig struct {
|
||||
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
|
||||
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
|
||||
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
|
||||
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
|
||||
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
|
||||
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
|
||||
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
|
||||
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
||||
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
||||
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
||||
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
||||
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
|
||||
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
|
||||
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
|
||||
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
|
||||
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
|
||||
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
|
||||
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
|
||||
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
||||
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
||||
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
||||
VirtualWallEnabled bool `yaml:"virtualWallEnabled" json:"virtualWallEnabled"`
|
||||
VirtualWallBackoffSpeed int `yaml:"virtualWallBackoffSpeed" json:"virtualWallBackoffSpeed"`
|
||||
VirtualWallBackoffMs int `yaml:"virtualWallBackoffMs" json:"virtualWallBackoffMs"`
|
||||
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -227,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,
|
||||
@@ -303,17 +312,20 @@ func LoadConfig(path string) (*Config, error) {
|
||||
Private: PrivateConfig{
|
||||
Enabled: false,
|
||||
Safety: PrivateSafetyConfig{
|
||||
SpeedLimitEnabled: false,
|
||||
SpeedLimitMaxWheelMMs: 250,
|
||||
HardOvercurrentEnabled: false,
|
||||
OvercurrentStopMs: 300,
|
||||
HardBumpEnabled: false,
|
||||
BumpBackoffSpeed: 250,
|
||||
BumpBackoffMs: 350,
|
||||
CliffEnabled: false,
|
||||
CliffBackoffSpeed: 250,
|
||||
CliffBackoffMs: 500,
|
||||
TriggerCooldownMs: 800,
|
||||
SpeedLimitEnabled: false,
|
||||
SpeedLimitMaxWheelMMs: 250,
|
||||
HardOvercurrentEnabled: false,
|
||||
OvercurrentStopMs: 300,
|
||||
HardBumpEnabled: false,
|
||||
BumpBackoffSpeed: 250,
|
||||
BumpBackoffMs: 350,
|
||||
CliffEnabled: false,
|
||||
CliffBackoffSpeed: 250,
|
||||
CliffBackoffMs: 500,
|
||||
VirtualWallEnabled: true,
|
||||
VirtualWallBackoffSpeed: 250,
|
||||
VirtualWallBackoffMs: 500,
|
||||
TriggerCooldownMs: 800,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -343,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)
|
||||
@@ -426,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"
|
||||
}
|
||||
@@ -470,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"
|
||||
}
|
||||
@@ -496,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"
|
||||
}
|
||||
@@ -516,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
|
||||
}
|
||||
|
||||
@@ -571,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
|
||||
@@ -594,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
|
||||
@@ -95,4 +96,10 @@ private:
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
cliffBackoffMs: 500
|
||||
# Virtual walls are default-on for private rovers because they mark a
|
||||
# deliberate boundary, and the server can escape by reversing the last
|
||||
# commanded wheel directions instead of always backing straight up.
|
||||
virtualWallEnabled: true
|
||||
virtualWallBackoffSpeed: 250
|
||||
virtualWallBackoffMs: 500
|
||||
triggerCooldownMs: 800
|
||||
|
||||
@@ -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:
|
||||
@@ -104,4 +102,10 @@ private:
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
cliffBackoffMs: 500
|
||||
# Virtual walls are default-on for private rovers because they mark a
|
||||
# deliberate boundary, and the server can escape by reversing the last
|
||||
# commanded wheel directions instead of always backing straight up.
|
||||
virtualWallEnabled: true
|
||||
virtualWallBackoffSpeed: 250
|
||||
virtualWallBackoffMs: 500
|
||||
triggerCooldownMs: 800
|
||||
|
||||
@@ -69,4 +69,10 @@ private:
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
cliffBackoffMs: 500
|
||||
# Virtual walls are default-on for private rovers because they mark a
|
||||
# deliberate boundary, and the server can escape by reversing the last
|
||||
# commanded wheel directions instead of always backing straight up.
|
||||
virtualWallEnabled: true
|
||||
virtualWallBackoffSpeed: 250
|
||||
virtualWallBackoffMs: 500
|
||||
triggerCooldownMs: 800
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
# Command system and optional Discord feature
|
||||
|
||||
## Purpose
|
||||
|
||||
Commands were originally implemented as part of the Discord bot. Web chat support was later added by adapting site chat messages into Discord-shaped messages and reusing the Discord command router. This leaves an important server capability owned by an optional external integration and creates inconsistent behavior between transports.
|
||||
|
||||
The command system should instead be an always-available server capability. Web chat and Discord should both be adapters for the same command system, while Discord itself becomes an optional feature that can be disabled without affecting commands or the rest of the server.
|
||||
|
||||
This is an internal architecture change. Existing behavior on the outside must remain unchanged unless this plan explicitly introduces a new command.
|
||||
|
||||
## Non-negotiable behavior
|
||||
|
||||
- Existing command names and syntax continue to work.
|
||||
- Existing permission and lockdown rules continue to work.
|
||||
- Existing web-chat command messages and replies continue to look and behave the same.
|
||||
- Existing Discord replies and embeds retain the same content, titles, field ordering, colors, timestamps, mention behavior, attachment names, progress updates, and edit behavior.
|
||||
- Existing Discord chat bridge, presence, moderation workflows, announcements, and other integrations continue to work when Discord is enabled.
|
||||
- Disabling Discord does not disable site-chat commands, replay generation, or unrelated server features.
|
||||
- Discord.js types, messages, embeds, guilds, channels, and configuration do not leak into the shared command implementation.
|
||||
- Replay hosting requires no new configuration. It must be automatic, conservative, and functional.
|
||||
- Backwards compatibility for obsolete internal architecture is not required after migration. Temporary migration adapters should be deleted when the new path is complete.
|
||||
|
||||
## Target dependency direction
|
||||
|
||||
```text
|
||||
Web chat adapter ---------+
|
||||
|
|
||||
v
|
||||
Operator command service ----> Existing server services
|
||||
^
|
||||
|
|
||||
Optional Discord adapter -+
|
||||
```
|
||||
|
||||
The operator command service owns parsing, command discovery, permission policy, execution, and neutral results. It does not know how a web-chat message or Discord message is represented.
|
||||
|
||||
The name `operatorCommandService` avoids confusion with the existing rover `commandService`, which sends operational commands to individual rovers.
|
||||
|
||||
## Command configuration
|
||||
|
||||
Command naming belongs to the command system rather than Discord:
|
||||
|
||||
```yaml
|
||||
commands:
|
||||
prefix: "rs"
|
||||
timeStatusCommand: "ts"
|
||||
```
|
||||
|
||||
Both web chat and Discord must read these same values. Prefix matching remains case-insensitive and must match a whole token so a prefix such as `rs` does not treat a word such as `rsvp` as a command.
|
||||
|
||||
Discord becomes an explicitly optional feature:
|
||||
|
||||
```yaml
|
||||
discord:
|
||||
enabled: false
|
||||
token: ""
|
||||
```
|
||||
|
||||
The existing Discord channel, role, site URL, and other settings stay under `discord`. `discord.enabled` is authoritative: a stored token must not silently enable the feature. If Discord is enabled but required credentials are missing or login fails, the failure is clearly logged and must not prevent the rest of the server from operating.
|
||||
|
||||
### Existing server feature system is authoritative
|
||||
|
||||
Use `server/src/helpers/features.js` as the single source of truth for whether optional features are configured and enabled. Do not add a command-specific feature registry, duplicate configuration checks inside command handlers, or infer availability independently from individual config fields.
|
||||
|
||||
- Add Discord to `buildFeatureFlags()` using the same explicit feature-gating pattern as the other optional server features. Discord is enabled only when `discord.enabled` is explicitly true and the required token is present.
|
||||
- Discord service bootstrap, command-adapter registration, integrations, presence, bridge behavior, alerts, and Discord replay delivery all consult the shared Discord feature flag.
|
||||
- Command definitions use `requiredFeature` metadata, and the command dispatcher resolves that metadata through `isFeatureEnabled()` or a feature-flags snapshot from the same helper.
|
||||
- Help availability and command execution use the same feature result so help cannot advertise a command as available when execution considers it disabled.
|
||||
- Lift and Neato availability comes from the existing `lift` and `neato` feature flags. Commands must not reproduce their Home Assistant, switch, device, or enabled-field checks.
|
||||
- Configuration-level feature availability is separate from runtime health. For example, an enabled lift may currently be disconnected, and configured Discord may fail login. The shared feature helper answers whether the feature is enabled and configured; the owning service remains authoritative for runtime readiness and returns a clear operational failure.
|
||||
- Replay generation and automatic local replay hosting are core server capabilities and are not feature-gated. Only the optional Discord delivery provider depends on the Discord feature flag and live Discord readiness.
|
||||
|
||||
When Discord is disabled:
|
||||
|
||||
- Do not construct a Discord client.
|
||||
- Do not attempt login.
|
||||
- Do not register Discord event handlers or event-bus integrations.
|
||||
- Do not register Discord chat bridge subscriptions.
|
||||
- Do not start Discord presence behavior.
|
||||
- Keep the shared command service and all site-chat commands active.
|
||||
|
||||
## Neutral command request
|
||||
|
||||
Every transport converts its native user/message state into one normalized request:
|
||||
|
||||
```js
|
||||
{
|
||||
text: 'rs lock alpha',
|
||||
source: 'web-chat',
|
||||
actor: {
|
||||
id: 'stable actor id',
|
||||
label: 'display name',
|
||||
role: 'admin',
|
||||
isAdmin: true,
|
||||
isLockdownAdmin: false,
|
||||
},
|
||||
context: {}
|
||||
}
|
||||
```
|
||||
|
||||
The web adapter derives the actor from the authenticated socket, identity, and role services. The Discord adapter derives it from the Discord user and configured administrator mapping. Command handlers consume the normalized actor and never inspect a socket or `message.author`.
|
||||
|
||||
Transport-specific context is allowed only for transport-specific extension commands. For example, the Discord-only bridge command needs guild and channel context, but shared commands must not depend on it.
|
||||
|
||||
## Command registry
|
||||
|
||||
Replace the large dispatcher switch and scattered help definitions with a command registry. A command definition should contain enough metadata to drive parsing, authorization, availability, and help:
|
||||
|
||||
```js
|
||||
{
|
||||
name: 'lift',
|
||||
category: 'feature',
|
||||
summary: 'Control the rover lift.',
|
||||
description: 'Show lift state or request upward or downward movement.',
|
||||
usage: ['lift status', 'lift up', 'lift down'],
|
||||
examples: ['rs lift status', 'rs lift down'],
|
||||
access: 'admin',
|
||||
lockdownAccess: 'lockdown-admin',
|
||||
requiredFeature: 'lift',
|
||||
execute,
|
||||
}
|
||||
```
|
||||
|
||||
The dispatcher should be responsible for common authorization. Individual handlers may perform finer-grained checks when subcommands truly require different access, but they should not duplicate the ordinary admin and lockdown gates.
|
||||
|
||||
## Command categories
|
||||
|
||||
Categories organize registration and help. Existing syntax must not be changed merely to add categories; for example, `rs mode` stays `rs mode` rather than becoming `rs admin mode`.
|
||||
|
||||
### System commands
|
||||
|
||||
General server information and server-wide user actions:
|
||||
|
||||
- `rs help`
|
||||
- `rs status`
|
||||
- `rs replay`
|
||||
- The configured time-status command, currently `ts`
|
||||
- Future health, session, or informational commands that do not belong to one optional feature
|
||||
|
||||
### Admin commands
|
||||
|
||||
Operational, access, and moderation controls:
|
||||
|
||||
- `rs lock`
|
||||
- `rs unlock`
|
||||
- `rs mode`
|
||||
- `rs kick`
|
||||
- `rs goal`
|
||||
- `rs reason`
|
||||
- `rs verify`
|
||||
- `rs deter`
|
||||
- `rs lights`
|
||||
|
||||
Existing admin and lockdown-admin policies remain authoritative.
|
||||
|
||||
### Feature commands
|
||||
|
||||
Commands belonging to optional hardware or server features. Initial additions should include:
|
||||
|
||||
- `rs lift status`
|
||||
- `rs lift up`
|
||||
- `rs lift down`
|
||||
- `rs neato status`
|
||||
- `rs neato start`
|
||||
- `rs neato home`
|
||||
- `rs neato locate`
|
||||
- `rs neato clear-errors`
|
||||
|
||||
Feature command handlers must call the existing feature services. They must not reimplement lift interlocks, cooldowns, connectivity checks, Home Assistant calls, Neato state rules, or other hardware safety logic. The feature service remains the source of truth and the command reports its result.
|
||||
|
||||
The dispatcher checks each command's `requiredFeature` against the existing server feature system before execution. The owning feature service then performs runtime availability and safety checks. This deliberately keeps configuration eligibility centralized in `helpers/features.js` while keeping live device state and operational rules inside the service that controls the feature.
|
||||
|
||||
Commands for an unavailable or disabled feature return a clear unavailable response rather than throwing or silently doing nothing.
|
||||
|
||||
### Discord-only commands
|
||||
|
||||
Discord bridge configuration is not a general server command. Keep `bridge` as a Discord extension command registered by the Discord adapter:
|
||||
|
||||
- `rs bridge`
|
||||
- `rs bridge here`
|
||||
- `rs bridge mode`
|
||||
- `rs bridge off`
|
||||
|
||||
These commands retain their current syntax and Discord behavior but do not appear as available commands in web chat.
|
||||
|
||||
## Organized help
|
||||
|
||||
Help is generated from registry metadata so command definitions and documentation cannot drift apart.
|
||||
|
||||
The default help should be detailed but scannable, grouped into System, Admin, and Features. Discord-only commands can appear in a Discord section when help is requested from Discord. Help should respect the configured prefix and time-status command.
|
||||
|
||||
Support focused help:
|
||||
|
||||
- `rs help system`
|
||||
- `rs help admin`
|
||||
- `rs help features`
|
||||
- `rs help status`
|
||||
- `rs help replay`
|
||||
- `rs help lift`
|
||||
- `rs help neato`
|
||||
- The same pattern for every registered command
|
||||
|
||||
Focused command help should include:
|
||||
|
||||
- A clear description
|
||||
- Required permission level
|
||||
- Availability or required feature
|
||||
- Accepted usage forms
|
||||
- Useful examples
|
||||
- Subcommand explanations where applicable
|
||||
|
||||
The registry provides neutral help data. Web chat renders readable plain text. Discord uses its own renderer and must preserve the established outward style. Improving organization must not accidentally change unrelated Discord embeds such as rover status and time status.
|
||||
|
||||
## Neutral command results and transport rendering
|
||||
|
||||
Shared handlers return neutral results instead of calling `message.reply()`:
|
||||
|
||||
```js
|
||||
{
|
||||
handled: true,
|
||||
ok: true,
|
||||
messages: [
|
||||
{
|
||||
kind: 'text',
|
||||
text: 'Locked Alpha.',
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Simple commands should return text results. Structured results should be used only where transports benefit from different faithful presentations, such as rover status, time status, help, administrative lists, or replay progress.
|
||||
|
||||
Discord renderers translate neutral results into the same Discord.js reply and embed objects used today. Existing embed builders should be extracted and retained where possible instead of visually rewriting them during this architecture change.
|
||||
|
||||
The web adapter translates the same results into the existing `Rover bot` system messages. The current behavior where the user's command remains visible in the chat transcript should remain unchanged.
|
||||
|
||||
## Replay architecture
|
||||
|
||||
Replay generation and replay delivery are separate responsibilities:
|
||||
|
||||
```text
|
||||
Replay request
|
||||
|
|
||||
v
|
||||
Replay engine builds one completed MP4
|
||||
|
|
||||
v
|
||||
Replay delivery coordinator
|
||||
|-- Discord is enabled, ready, and replay channel works
|
||||
| -> upload MP4 to Discord
|
||||
| -> use returned Discord attachment URL
|
||||
|
|
||||
`-- Discord unavailable, unconfigured, or upload fails
|
||||
-> store MP4 under the server data directory
|
||||
-> use server-hosted media URL
|
||||
|
|
||||
v
|
||||
Publish the playable replay media payload to clients
|
||||
```
|
||||
|
||||
Discord remains the preferred host when it is configured for replay delivery. A Discord upload failure after a successful replay build must fall back to local hosting instead of failing the replay. The Discord failure should be logged clearly, while clients still receive a working replay.
|
||||
|
||||
The common client media payload should remain compatible with the current payload so `/mini`, `/display`, spectator clients, and other replay consumers behave the same. Discord-specific metadata remains present when Discord hosted the media. Locally hosted media supplies the same common playable URL and media fields without pretending to be a Discord attachment.
|
||||
|
||||
### Automatic local replay hosting
|
||||
|
||||
No replay-hosting configuration is added. Use conservative internal constants chosen after checking typical generated replay sizes.
|
||||
|
||||
The local media service should:
|
||||
|
||||
- Store completed files in `data/replays/` through the canonical data-directory helper.
|
||||
- Use random, non-guessable IDs in public filenames.
|
||||
- Expose a deliberate route such as `/media/replays/:id.mp4` rather than placing runtime media in built web assets.
|
||||
- Support HTTP range requests so browsers can seek and play MP4 files normally.
|
||||
- Set the correct media type and safe cache headers.
|
||||
- Write atomically by completing a temporary file and renaming it into place.
|
||||
- Never expose or delete a file that is still being written.
|
||||
- Remove abandoned temporary files.
|
||||
- Delete expired replay files during server startup.
|
||||
- Run one lightweight periodic cleanup while the server is running.
|
||||
- Stop the cleanup timer during graceful shutdown if the server has a shutdown lifecycle.
|
||||
- Enforce both a conservative age limit and a conservative total storage ceiling.
|
||||
- Delete the oldest completed files first when the storage ceiling is exceeded.
|
||||
- Treat cleanup errors as logged, nonfatal maintenance failures.
|
||||
- Prevent path traversal and serve only known replay filenames from the replay directory.
|
||||
|
||||
Cleanup must operate only on the hosted replay directory and must not touch replay frame caches, unrelated data files, or active replay builds.
|
||||
|
||||
## Optional Discord feature boundary
|
||||
|
||||
The Discord feature owns:
|
||||
|
||||
- Discord client creation and login
|
||||
- Intents and partials
|
||||
- Discord message-to-command adaptation
|
||||
- Neutral-result-to-Discord rendering
|
||||
- Existing embed presentation
|
||||
- Discord replay upload delivery
|
||||
- Chat bridge and webhook behavior
|
||||
- Guild bridge storage and bridge commands
|
||||
- Presence
|
||||
- Discord announcements and alerts
|
||||
- DM verification and private-access moderation workflows
|
||||
- Reactions and Discord event handling
|
||||
|
||||
Discord must be added to and activated through the existing server feature system. The Discord entrypoint must not maintain a separate interpretation of `discord.enabled` and token availability. Runtime client readiness may still be tracked inside the Discord feature for operations such as replay upload, but that readiness supplements rather than replaces the shared configuration feature flag.
|
||||
|
||||
The Discord feature may import the operator command service. The operator command service, replay engine, chat service, and feature command handlers must not import the Discord feature or Discord.js.
|
||||
|
||||
## Focused regression protection
|
||||
|
||||
The existing implementation is the reference for current command wording and behavior. Read and preserve that behavior while moving each handler; do not first catalogue every reply or build exhaustive snapshots for all commands.
|
||||
|
||||
Use focused tests and practical checks at the boundaries most likely to cause meaningful regressions:
|
||||
|
||||
- Discord status and time-status embeds retain their existing content, structure, colors, field order, timestamps, and links.
|
||||
- Discord replay progress edits, attachment upload, filename, URL extraction, and client media publication continue to work.
|
||||
- A failed or unavailable Discord replay delivery falls back to working locally hosted media.
|
||||
- Commands remain operational when Discord is disabled or fails login.
|
||||
- Web chat and Discord use the same configured prefix and whole-token matching behavior.
|
||||
- Admin and lockdown permissions are enforced consistently from both transports.
|
||||
- Disabled feature commands return a clear unavailable result, while enabled feature commands use their owning service's runtime safety checks.
|
||||
- Hosted replay routes support playback and seeking, reject invalid paths, and cleanup only expired completed media.
|
||||
|
||||
Use direct inspection and practical command checks for ordinary response wording. Additional tests are appropriate when complex logic is extracted, but exhaustive output transcription is not a prerequisite for the refactor.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
Build directly toward the final architecture. It is acceptable to move commands in logical groups while working, but avoid investing in a durable old/new compatibility framework. Once a replacement path works, remove the obsolete adapter and duplicated implementation.
|
||||
|
||||
1. Add the operator command request, actor, result, parser, registry, authorization, and help foundations.
|
||||
2. Extract existing Discord formatting and embed construction into transport-owned renderers without changing their output.
|
||||
3. Move existing system and admin commands into the registry, using their current code as the behavioral reference.
|
||||
4. Move status and time status while separating neutral data collection from unchanged Discord embed rendering.
|
||||
5. Add organized registry-driven help with transport-specific output.
|
||||
6. Add lift and Neato feature command families using the existing feature flags, services, and safety rules.
|
||||
7. Add the automatic local replay media store, HTTP route, range serving, startup cleanup, periodic cleanup, and storage limits.
|
||||
8. Split replay generation from delivery and add the Discord-preferred/local-fallback delivery coordinator.
|
||||
9. Move replay onto the shared command service while preserving existing Discord progress and upload behavior.
|
||||
10. Convert web chat and Discord to the shared command service and move bridge commands into the Discord-only extension registry.
|
||||
11. Add Discord to the existing feature system and gate all Discord bootstrap and integrations through it.
|
||||
12. Remove the Discord-owned shared router, fake Discord message objects, web replay command injection, result-flattening workaround, and duplicate replay paths.
|
||||
13. Add or update focused tests for the high-risk boundaries listed above.
|
||||
14. Run server tests, practical command checks, the web UI build, and targeted lint for touched files.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
- The server has one transport-neutral command registry and execution path.
|
||||
- Web chat commands work with Discord completely disabled.
|
||||
- Discord consumes the shared command service as an optional adapter.
|
||||
- The configured command prefix behaves consistently everywhere.
|
||||
- Help is organized by System, Admin, Features, and Discord-only extensions where applicable.
|
||||
- Detailed per-command and per-category help is available.
|
||||
- Lift and Neato commands use existing service safety and availability behavior.
|
||||
- Discord-hosted replays behave exactly as before when Discord delivery succeeds.
|
||||
- Replays automatically fall back to maintained server-hosted media without configuration.
|
||||
- Existing clients continue receiving compatible replay media payloads.
|
||||
- Existing Discord embeds and outward behavior remain unchanged.
|
||||
- Temporary adapters and duplicated command logic are removed.
|
||||
@@ -0,0 +1,62 @@
|
||||
# the inter-instance API and system
|
||||
A single API endpoint that returns one json object with information about this instance of this server, meant to display on other servers.
|
||||
A centralized json file pulled from a simple link on the internet which contains a list of public server instances
|
||||
Basically, designed so that everyone's rover servers can show on everyone else's rover servers in some way.
|
||||
In the end once its all working, users will be able to see rovers from other instances on any other instance, click on a rover, and just via a simple href with a few URL params, it will put you on that instance, that rover, and transfer your cookie object through a URL parameter.
|
||||
|
||||
## centralized json file of public instances
|
||||
- contains a list of simple URLs, like:
|
||||
```["https://rover.otter.land"], ["http://14.84.27.47:8080]```
|
||||
- all servers will use the same link to the same json file by default (this will be to a file on github or something)
|
||||
- there is an option for multiple links, for redundancy. but it only comes with one in the config.
|
||||
- this should be ONLY a list of links, maybe with placeholder names to show in the UI if one of them is offline
|
||||
- if my server had the two example links above, it would contact both info API endpoints from both of those separate instances for information about them.
|
||||
- if a new server is to be added, add it to the centralized json file and that instance will show on all other instances, and it will show all other instances on itself.
|
||||
|
||||
## the general concept of the inter-instance API system
|
||||
- every server hosts the same API endpoint which returns one big json object for that instance
|
||||
- every server automatically gets the list of instances from the centralized json file
|
||||
- every server automatically requests all of the other inter-instance information from all the other servers
|
||||
- every server will show the info from all the other servers on it's web UI.
|
||||
|
||||
## what information will the servers get from the other servers?
|
||||
- servers will get a bunch of info from the other servers which they poll the APIs of
|
||||
- this information will, for the most part, just be sent straight to the web UI where most of the data moving will happen
|
||||
- at least these things will need to be communicated
|
||||
- is the server open? (turns/open access mode)
|
||||
- server name
|
||||
- server color for UI
|
||||
- non-optional description
|
||||
- an object of rovers containing, for each rover,
|
||||
- rover name
|
||||
- rover battery level
|
||||
- any users on it?
|
||||
- rover color
|
||||
- rover description
|
||||
- locked?
|
||||
- locked reason
|
||||
- basically, all the info that the webui uses now to show a rover in the rover roster
|
||||
- maybe an object containing feature states, from the system of features.js in the server, so people can see what features that instance does and doesn't have
|
||||
- MAYBE could even have images that are derived from that instance's URL that the web UI can use to show room cameras if they exist or rover snapshots
|
||||
|
||||
## what will this look like in the web UI?
|
||||
- a button at the bottom of the rover roster that says show external rovers or something
|
||||
- when you hit this button it shows the external rovers in the same roster stuff as the local instance rovres
|
||||
- when this is expanded theres a button to open the shared inter-instance component in a popup
|
||||
- a new component, a cardframe, which will be a component shared in multiple spots. contains:
|
||||
- the instances
|
||||
- the instance info, name, description, etc
|
||||
- the rovers in the instances and their statuses
|
||||
- the features that the instance has
|
||||
- ALSO show this same cardframe on the admin lock overlay, so people can see other instances while their current one is locked
|
||||
- all new UI has to be mobile friendly.
|
||||
|
||||
|
||||
## switching to a different instance from a previous one
|
||||
- users should be able to click on a rover from the listing of another instance, and be put on that rover on that instance.
|
||||
- this should just be a thing that takes you to a new link to the new instance, with a couple of URL params.
|
||||
- when switching, have a URL param for the rover that theyre requesting,
|
||||
- this URL param should just make the web UI automatically request the rover from the param.
|
||||
- and another URL param, which:
|
||||
- takes their ENTIRE identity / settings cookie over to the new instance, by encoding the json in base64 in the URL.
|
||||
- when the web UI takes this URL in, it should replace the cookie with the one from the URL. maybe with a popup first that asks to transfer your identity from previous instance to the new one?
|
||||
@@ -0,0 +1,171 @@
|
||||
# ONVIF first, reolink specifics second PTZ camera integration
|
||||
|
||||
## what where who how
|
||||
- adding support for a reolink PTZ camera
|
||||
- ideally control everything over ONVIF
|
||||
- if needed for some of the special features, use https://github.com/verheesj/reolink-api
|
||||
- VIP (verified user) feature only
|
||||
- due to upload bandwidth limitations (ONLY UPLOAD TO USERS MATTERS HERE NOT INTERNAl NETWORK STUFF), only one person should be on the camera at a time. only one person at a time should view
|
||||
- the ptz camera should have a queue and turns that are like 5 mintues long or so, so no one can hog it
|
||||
- if you are the camera operator, you are not on a rover. ever.
|
||||
- if you are a spectator, you can see the snapshots for it
|
||||
- local spectators should get full video like they already do now though
|
||||
- the camera needs to be a replay source
|
||||
- camera video needs to go through the same pipeline as rover video does and get to the client over webRTC
|
||||
|
||||
## camera learnings
|
||||
- scan for all onvif features that the camera has
|
||||
|
||||
## UI flow:
|
||||
- whole UI should be very technical and utilitarian
|
||||
- use cardframe for everything
|
||||
- match global styling
|
||||
- new card in VIP tab
|
||||
- shows whoevers on the camera
|
||||
- a very slow snapshot of the camera view
|
||||
- maybe some other stats
|
||||
- a big button to open the camera controller
|
||||
- the fullscreen camera interface
|
||||
- the rest of the site needs to go away when this is open
|
||||
- when its open, it takes over your rover controls. whatever they are
|
||||
- easy route for this could be to intercept it right before the control goes to the server, so any control gets converted to a ptz control
|
||||
- desktop
|
||||
- movement controls pan and tilt
|
||||
- camera up / down controls zoom
|
||||
- headlight and laser buttons hopefully control spotlight and IR light or something
|
||||
- fullscreen inteface
|
||||
- right sidebar with info and controls info
|
||||
- mobile
|
||||
- uhhh idk
|
||||
- obviously, camera on the screen
|
||||
- probably add a variant of the mobile controls just to retitle the things from the rover controls to the camera controls
|
||||
- and just use the same control columns
|
||||
|
||||
-- slop generated below --
|
||||
|
||||
## clarified implementation direction
|
||||
|
||||
This is not intended to become a generic ONVIF camera framework. The camera integration is for one specific Reolink PTZ camera. Once the camera arrives, we will run a one-time ONVIF capability discovery against that exact camera, record what it exposes, and then build the integration around those known capabilities.
|
||||
|
||||
The one-time discovery should capture:
|
||||
- ONVIF services exposed by the camera
|
||||
- media profiles and stream URIs
|
||||
- snapshot URI support
|
||||
- PTZ support and movement modes
|
||||
- pan/tilt/zoom ranges and speed ranges
|
||||
- preset/home support
|
||||
- imaging controls
|
||||
- any ONVIF-exposed spotlight, IR, or night-vision controls
|
||||
- whether PTZ status reporting is reliable
|
||||
|
||||
After that, runtime code should assume this known camera profile instead of trying to dynamically support every possible ONVIF camera.
|
||||
|
||||
## claiming and operator rules
|
||||
|
||||
The PTZ camera is a single scarce controllable resource.
|
||||
|
||||
Only verified/VIP users can claim it during normal operation. Only one user can operate it at a time. The active operator gets live WebRTC video and PTZ control for a limited turn, probably around five minutes. Other remote users should only receive slow snapshots. Local spectators may be allowed live video because LAN traffic is not the bandwidth problem.
|
||||
|
||||
A user operating the PTZ camera must not also be operating a rover. When a user tries to move from a rover to PTZ, the existing rover-switch safety rule should be reused: switching is allowed if another driver remains on that rover, or if the current rover is docked and charging. Otherwise, the server should block the PTZ handoff and tell the user to dock and charge their rover first.
|
||||
|
||||
This should be implemented by refactoring the existing rover switching check into a shared helper, such as `canLeaveCurrentRover(socket)`, then using that helper from both rover switching and PTZ claiming.
|
||||
|
||||
## streaming model
|
||||
|
||||
Camera video should come from the Reolink camera over the local network, likely RTSP into MediaMTX. Browser playback should use the existing MediaMTX WHEP/WebRTC pipeline.
|
||||
|
||||
The existing video session and MediaMTX auth system should be extended with a `ptz` source type. Remote live WHEP access should be allowed for the current PTZ operator, local spectators, and authorized admins according to normal server rules. Remote non-operators should not get live video.
|
||||
|
||||
Slow snapshots should use a PTZ-specific snapshot path or socket gateway, modeled after the existing room camera snapshot system, but with PTZ-specific authorization rules.
|
||||
|
||||
## lockdown behavior
|
||||
|
||||
No extra UI work is needed for lockdown because the app already visually blocks things in lockdown mode.
|
||||
|
||||
Server-side lockdown enforcement is still required everywhere. In lockdown mode, only lockdown admins/users may claim, queue, operate, subscribe to snapshots, request live PTZ video, or use PTZ replay sources. If lockdown starts while a normal user is operating PTZ, the server should immediately revoke their operator state, remove them from the PTZ queue if needed, revoke PTZ video sessions, and stop accepting PTZ commands from them.
|
||||
|
||||
## reusable existing systems
|
||||
|
||||
Strong reuse targets:
|
||||
- rover switch safety logic from `roverManager/roverLifecycle.js`
|
||||
- `videoSessions`
|
||||
- `videoSocketService`
|
||||
- `videoAuthService`
|
||||
- `WhepPlayer`
|
||||
- `sessionService` session sync
|
||||
- VIP panel/card structure
|
||||
- alert system
|
||||
- replay source validation and replay worker architecture
|
||||
|
||||
Adapted reuse targets:
|
||||
- turn queue/timer structure from `turnService`
|
||||
- turn alert listener behavior
|
||||
- room camera snapshot socket/feed pattern
|
||||
- `RoomCameraFeed` for slow preview display
|
||||
- replay source catalog and ffmpeg workers
|
||||
- existing control input concepts, but with a PTZ-specific command pipeline
|
||||
|
||||
Do not directly merge PTZ into `roomCameraService` or `commandService`. PTZ should have its own service boundary because it has ownership, queueing, ONVIF control, video authorization, and camera-specific state.
|
||||
|
||||
## operator UI and controls
|
||||
|
||||
The VIP tab should get a PTZ camera card. The card should be technical/utilitarian and match the existing site style. It should show:
|
||||
- current camera operator
|
||||
- queue/turn state
|
||||
- turn time remaining when relevant
|
||||
- whether the current user can claim or must wait
|
||||
- whether the current user must dock and charge before switching
|
||||
- a slow snapshot preview
|
||||
- a button to open the fullscreen PTZ controller when the user is the active operator
|
||||
|
||||
The fullscreen PTZ controller should take over the whole app surface while open. It should not feel like a normal side panel. When active, the user is in camera-operation mode, not rover-driving mode.
|
||||
|
||||
Desktop controls:
|
||||
- movement input pans and tilts the camera
|
||||
- camera up/down or equivalent camera tilt controls zoom in/out
|
||||
- available special controls expose only what the one-time ONVIF probe proved exists
|
||||
- if ONVIF exposes presets/home, provide those controls
|
||||
- if ONVIF exposes spotlight, IR, or night mode, provide those controls
|
||||
- if those features are not exposed through ONVIF, leave them out until a Reolink-specific fallback is intentionally added
|
||||
- include a compact right-side status/control panel with operator, queue, camera state, and available controls
|
||||
|
||||
Mobile controls:
|
||||
- reuse the existing mobile control layout concept where practical
|
||||
- relabel/re-map rover movement controls for PTZ movement
|
||||
- keep the camera view as the main screen
|
||||
- use the existing mobile control columns/pads as inspiration, but send PTZ commands instead of rover commands
|
||||
|
||||
Input/control implementation:
|
||||
- do not send PTZ through the existing rover `commandService`
|
||||
- create PTZ-specific socket events/handlers owned by the PTZ camera service
|
||||
- use a PTZ-specific client command pipeline that maps existing input intent into PTZ commands
|
||||
- server must enforce that only the active PTZ operator can send movement/zoom/control commands
|
||||
- client-side input interception is only for UX; server-side operator checks are the real authority
|
||||
- all movement controls should send stop commands on key/button release, blur, disconnect, controller close, or turn loss
|
||||
|
||||
## NEW UI STUFF
|
||||
- desktop:
|
||||
- sidebar like there is now
|
||||
- replay panel in sidebar
|
||||
- better indicators of light and OR modes
|
||||
- list of controls using keybind things
|
||||
- mobile:
|
||||
- one sidebar on the right
|
||||
- reuse rover drive control panel for camera movement
|
||||
- reuse gpio toggle buttons for spotlight and IR
|
||||
- reuse camera tilt slider for zoom
|
||||
- relabeled variants where needed for reused mobile controls
|
||||
- scroll sidebar down to see replay panel
|
||||
- both:
|
||||
- the VIP panel
|
||||
- sucks.
|
||||
- wasted space
|
||||
- put snapshot and everything else side by side
|
||||
- add a display of ptz's turn queue
|
||||
- camera should open when you request control over it. no need to have it be another button to press
|
||||
- should show state of camera
|
||||
- the fullscreen interface
|
||||
- should be inside a cardframe, with no title bar
|
||||
- reuse anything whereever possible
|
||||
- needs to be ACTUALLY FULLSCREEN, not with space around the edges anywhere
|
||||
- needs to match the global styling, and use cardframes internally for stuff.
|
||||
@@ -0,0 +1,33 @@
|
||||
- make ptz camera better integrated
|
||||
- keep current fullscreen interface, its good.
|
||||
- but remove the card from the vip panel
|
||||
- clean up the fullscreen interface to match the rest of the page better
|
||||
- have a clear close button
|
||||
- on desktop, have some stuff in sidebar and some stuff below the video
|
||||
- video should keep the rest of the space
|
||||
- reuse rover HUD elements like chat input and not your turn indicator
|
||||
- probably make it so that the rest of the page unmounts or unloads or whatever when youre in it
|
||||
- make it feel more like youre switching to a different rover instead of switching to a completely different thing
|
||||
- simplify and reuse components wherever possible, frontend and backend
|
||||
- right now, it feels tacked on, badly integrated, and incomplete
|
||||
- needs a much better UI flow
|
||||
- still needs to be a VIP feature
|
||||
- for users on ptz, make their chats have a rover badge that has the ptz name and a color
|
||||
- make the cam show up as a room camera in the room camera panel
|
||||
- snapshot mode only
|
||||
- make the ptz queue and join button show up as one roverqueuespanel style rover row below the links panel
|
||||
- only show it open for verified users, for non-verified users overlay it with a message and dont let them click on it
|
||||
- for mobile layouts, show it below the roverqueuepanel.
|
||||
- dont worry about not being invasive, just dont break anything
|
||||
|
||||
## ui flow should be:
|
||||
1. you are verified
|
||||
2. you see the ptz camera queue in the ui, it has 2 people in it
|
||||
3. you click on it, the fullscreen UI opens
|
||||
- other people will see you in the queue in the little panel
|
||||
4. its not your turn yet. the fullscreen UI replaces the page.
|
||||
- you see snapshots, you see the "not your turn" hud, same as driving a rover
|
||||
5. its now your turn. the overlay shows up just as it does in rover hud
|
||||
6. you control the camera like usual, you want to close it
|
||||
7. you hit the close button, you get removed from the queue
|
||||
8. the page returns to normal
|
||||
@@ -0,0 +1,8 @@
|
||||
# why?
|
||||
for anyone to be able to run a server, without all the specialty random interactive hardware.
|
||||
|
||||
## what?
|
||||
- make it so the entire server and web UI can work with ONLY ROVERS and nothing else
|
||||
- make sure that any extra feature can be disabled server-side, and when disabled it disappears from the web UI without a trace. no empty panels that say "nothing configured"
|
||||
- ONLY mess with features that require extra hardware.
|
||||
- make all extra integrations that arent only software be disabled on install, so if you want to add support for one you enable it manually.
|
||||
@@ -0,0 +1,32 @@
|
||||
# make all bandwidth saving options toggleable in one centralized server config
|
||||
- external spectators are people outside of local network
|
||||
|
||||
- multitab protection mode
|
||||
- allowed
|
||||
- verified only
|
||||
- not allowed
|
||||
- snapshots
|
||||
- non-turn video
|
||||
- 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)
|
||||
- external spectator access (new)
|
||||
- off (no one can access the spectate page externally)
|
||||
- on (everyone can access the spectate page externally)
|
||||
- verifiedOnly (only verified identities can access the spectate page externally)
|
||||
- admin (external spectators need a saved spectatorAccess.external identity grant)
|
||||
- anything else related to bandwidth savings should also get config
|
||||
|
||||
## implemented config shape
|
||||
```yaml
|
||||
bandwidthSavings:
|
||||
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
|
||||
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
|
||||
```
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Chrome Google TTS WAV renderer.
|
||||
|
||||
Purpose: Converts the same local ChromeOS Google TTS assets used by rovers into
|
||||
server-side WAV files that can be handed to another playback transport.
|
||||
Scope: This script only renders one utterance to a file; device playback and
|
||||
camera delivery stay owned by Node services.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import wave
|
||||
|
||||
|
||||
ASSET_ROOT = "/opt/roverd/googletts"
|
||||
LIB_PATH = os.path.join(ASSET_ROOT, "libchrometts.so")
|
||||
VOICE_DIR = os.path.join(ASSET_ROOT, "en-us-x-multi-r30")
|
||||
PIPELINE = "pipeline.pb"
|
||||
SAMPLE_RATE = 24000
|
||||
MAX_TEXT_CHARS = 512
|
||||
|
||||
VOICES = {
|
||||
"sfg": "female",
|
||||
"iob": "female",
|
||||
"iog": "female",
|
||||
"iol": "male",
|
||||
"iom": "male",
|
||||
"tpc": "female",
|
||||
"tpd": "male",
|
||||
"tpf": "female",
|
||||
}
|
||||
DEFAULT_VOICE = "tpf"
|
||||
DEFAULT_PITCH = 1.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
MIN_PITCH = 0.5
|
||||
MAX_PITCH = 2.0
|
||||
MIN_SPEED = 0.5
|
||||
MAX_SPEED = 2.0
|
||||
|
||||
|
||||
def varint(value):
|
||||
out = bytearray()
|
||||
while value >= 0x80:
|
||||
out.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
out.append(value)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def field_bytes(number, payload):
|
||||
return varint((number << 3) | 2) + varint(len(payload)) + payload
|
||||
|
||||
|
||||
def field_float(number, value):
|
||||
return varint((number << 3) | 5) + struct.pack("<f", float(value))
|
||||
|
||||
|
||||
def build_utterance(text, pitch=1.0, speed=1.0):
|
||||
params = field_float(2, pitch) + field_float(3, speed)
|
||||
msg_b = field_bytes(1, text.encode("utf-8")) + field_bytes(20, params)
|
||||
msg_a = field_bytes(1, msg_b)
|
||||
return field_bytes(1, msg_a)
|
||||
|
||||
|
||||
def build_speaker(name, gender):
|
||||
return field_bytes(1, name.encode("utf-8")) + field_bytes(2, gender.encode("utf-8"))
|
||||
|
||||
|
||||
def clamp_float(value, minimum, maximum, fallback):
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if value <= 0:
|
||||
return fallback
|
||||
if value < minimum:
|
||||
return minimum
|
||||
if value > maximum:
|
||||
return maximum
|
||||
return value
|
||||
|
||||
|
||||
def float_to_s16le(samples):
|
||||
pcm = bytearray()
|
||||
for sample in samples:
|
||||
clipped = max(-1.0, min(1.0, float(sample)))
|
||||
pcm.extend(struct.pack("<h", int(clipped * 32767)))
|
||||
return bytes(pcm)
|
||||
|
||||
|
||||
class ChromeTTS:
|
||||
def __init__(self):
|
||||
self.lib = ctypes.CDLL(LIB_PATH)
|
||||
self.lib.GoogleTtsInit.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
self.lib.GoogleTtsInit.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsInitBuffered.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.GoogleTtsInitBuffered.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.argtypes = []
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.restype = ctypes.c_size_t
|
||||
self.lib.GoogleTtsReadBuffered.argtypes = [
|
||||
ctypes.POINTER(ctypes.c_float),
|
||||
ctypes.POINTER(ctypes.c_size_t),
|
||||
]
|
||||
self.lib.GoogleTtsReadBuffered.restype = ctypes.c_int
|
||||
self.lib.GoogleTtsShutdown.argtypes = []
|
||||
self.lib.GoogleTtsShutdown.restype = None
|
||||
|
||||
voice_dir = os.path.abspath(VOICE_DIR) + os.sep
|
||||
pipeline = os.path.join(voice_dir, PIPELINE)
|
||||
if not self.lib.GoogleTtsInit(pipeline.encode("utf-8"), voice_dir.encode("utf-8")):
|
||||
raise RuntimeError("GoogleTtsInit failed")
|
||||
self.frames = int(self.lib.GoogleTtsGetFramesInAudioBuffer())
|
||||
if self.frames <= 0:
|
||||
raise RuntimeError("invalid Google TTS audio buffer size")
|
||||
self.buffer = (ctypes.c_float * self.frames)()
|
||||
|
||||
def render_wav(self, text, output_path, voice, pitch=DEFAULT_PITCH, speed=DEFAULT_SPEED):
|
||||
voice = voice if voice in VOICES else DEFAULT_VOICE
|
||||
pitch = clamp_float(pitch, MIN_PITCH, MAX_PITCH, DEFAULT_PITCH)
|
||||
speed = clamp_float(speed, MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("text required")
|
||||
text = text[:MAX_TEXT_CHARS]
|
||||
|
||||
utterance = build_utterance(text, pitch=pitch, speed=speed)
|
||||
speaker = build_speaker(voice, VOICES[voice])
|
||||
if not self.lib.GoogleTtsInitBuffered(utterance, speaker, len(utterance), len(speaker)):
|
||||
raise RuntimeError("GoogleTtsInitBuffered failed")
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
with wave.open(output_path, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(SAMPLE_RATE)
|
||||
frames_written = ctypes.c_size_t(0)
|
||||
while self.lib.GoogleTtsReadBuffered(self.buffer, ctypes.byref(frames_written)) > 0:
|
||||
count = int(frames_written.value)
|
||||
if count > 0:
|
||||
wav.writeframes(float_to_s16le(self.buffer[:count]))
|
||||
|
||||
def shutdown(self):
|
||||
self.lib.GoogleTtsShutdown()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Render Chrome Google TTS to a WAV file.")
|
||||
parser.add_argument("--text", required=True)
|
||||
parser.add_argument("--voice", default=DEFAULT_VOICE)
|
||||
parser.add_argument("--pitch", type=float, default=DEFAULT_PITCH)
|
||||
parser.add_argument("--speed", type=float, default=DEFAULT_SPEED)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
tts = ChromeTTS()
|
||||
try:
|
||||
tts.render_wav(args.text, args.output, args.voice, args.pitch, args.speed)
|
||||
finally:
|
||||
tts.shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"chromegtts-wav failed: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
+161
-34
@@ -7,12 +7,27 @@ admins:
|
||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
|
||||
timezone: "America/New_York"
|
||||
|
||||
interInstance:
|
||||
enabled: false
|
||||
directoryUrls:
|
||||
- "https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json"
|
||||
pollIntervalMs: 30000
|
||||
requestTimeoutMs: 5000
|
||||
profile:
|
||||
publicUrl: "https://rover.example.com"
|
||||
name: "Example Rover Server"
|
||||
description: "A short public description of this rover server."
|
||||
color: "#38bdf8"
|
||||
|
||||
llmCommentary:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
|
||||
overseerControl:
|
||||
enabled: false
|
||||
# autonomous runs the existing vote-gated loop forever; directAddress only
|
||||
@@ -27,14 +42,55 @@ overseerControl:
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
profileImageUrl: "https://example.com/overseer.png"
|
||||
gateIntervalMs: 2000
|
||||
|
||||
barcodeGames:
|
||||
enabled: false
|
||||
botName: "Barcode Games"
|
||||
profileImageUrl: "https://example.com/barcode-games.png"
|
||||
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
# Example: http://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.
|
||||
# allowed: no duplicate-tab protection
|
||||
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
||||
# notAllowed: every identity is limited to one driver tab
|
||||
multiTabProtection: "verifiedOnly"
|
||||
# Disconnect rover video when its player is outside the viewport or the web
|
||||
# page is in a background browser tab. Rover audio is a separate stream and
|
||||
# remains connected. /mini intentionally keeps its existing always-warm video
|
||||
# behavior regardless of this option.
|
||||
pauseHiddenRoverVideo: false
|
||||
# Video for users who are attached to a source but do not currently own its
|
||||
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
|
||||
# whenever the normal mode/visibility rules allow it.
|
||||
nonTurnVideo:
|
||||
mode: "snapshots"
|
||||
# Snapshot mode activates only when controllable users exceed this number.
|
||||
# A controllable user is attached to a rover or PTZ as operator/queue, not a
|
||||
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
|
||||
# actually attached to a controllable source.
|
||||
userThreshold: 0
|
||||
# Live video for spectators outside the local network. Local spectators are
|
||||
# not restricted by this switch because LAN traffic is not the upload limit.
|
||||
externalSpectatorVideo: "snapshots"
|
||||
# Whether non-local users may enter the spectator page.
|
||||
# off: block external spectators
|
||||
# on: allow external spectators
|
||||
# verifiedOnly: require a verified identity, but no separate spectator grant
|
||||
# admin: require an identity feature-state grant at spectatorAccess.external
|
||||
externalSpectatorAccess: "on"
|
||||
|
||||
audioForward:
|
||||
enabled: true
|
||||
@@ -43,19 +99,27 @@ 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
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
neato:
|
||||
enabled: false
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
lift:
|
||||
enabled: false
|
||||
# Two Home Assistant switches controlling lift direction.
|
||||
# Raise sequence: down off -> wait interlockMs -> up on
|
||||
# Lower sequence: up off -> wait interlockMs -> down on
|
||||
@@ -92,17 +156,36 @@ homeAssistant:
|
||||
stateEquals: "toggle"
|
||||
cooldownMs: 1000
|
||||
action: "lightsLockToggle"
|
||||
|
||||
roomCameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
enabled: false
|
||||
cameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
|
||||
ptzCamera:
|
||||
enabled: false
|
||||
name: "PTZ Camera"
|
||||
host: "192.168.0.8"
|
||||
onvifPort: 8000
|
||||
username: "admin"
|
||||
password: "REPLACE_WITH_CAMERA_PASSWORD"
|
||||
# The Reolink TrackMix autotrack profile was token 003 during commissioning.
|
||||
# Keeping this configurable lets firmware/profile resets be fixed without code
|
||||
# changes while the integration still remains a single-camera feature.
|
||||
profileToken: "003"
|
||||
turnDurationMs: 300000
|
||||
# PTZ replay capture needs a known-good replay encoder on the server. Keep it
|
||||
# off by default so adding live PTZ does not start a broken replay worker loop.
|
||||
replayEnabled: false
|
||||
|
||||
kinect:
|
||||
enabled: false
|
||||
@@ -111,7 +194,27 @@ 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
|
||||
|
||||
barcodeScanner:
|
||||
enabled: false
|
||||
|
||||
commands:
|
||||
# Commands are a core server capability shared by site chat and optional
|
||||
# transports. Their names therefore do not belong to Discord configuration.
|
||||
prefix: "rs"
|
||||
# Set this to null to disable the legacy bare time-status shortcut.
|
||||
timeStatusCommand: "ts"
|
||||
|
||||
discord:
|
||||
# Discord is optional. A token by itself never enables an external login.
|
||||
enabled: false
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
siteUrl: "https://rover.example.com"
|
||||
@@ -119,7 +222,7 @@ discord:
|
||||
general: "123456789012345678"
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
# chat bridge is configured per guild via `rs bridge` commands
|
||||
# chat bridge is configured per guild via the shared `commands.prefix`
|
||||
replay: "123456789012345678"
|
||||
humanAlerts: "123456789012345678"
|
||||
roles:
|
||||
@@ -129,23 +232,47 @@ discord:
|
||||
humanAlertPing: "123456789012345678"
|
||||
|
||||
socials:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
- id: "wiki"
|
||||
label: "Wiki"
|
||||
url: "https://wiki.example.com"
|
||||
icon: "FaBook"
|
||||
color: "#475569"
|
||||
- id: "throne"
|
||||
label: "Throne"
|
||||
url: "https://throne.me/yourname"
|
||||
icon: "FaCrown"
|
||||
color: "#334155"
|
||||
enabled: false
|
||||
links:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
|
||||
# Optional trusted HTML card shown at the bottom of the desktop driver page's
|
||||
# left column. Leave html empty (or omit this section) to hide the card. This
|
||||
# content is sent to driver browsers without sanitization, so only place markup
|
||||
# here that is controlled by the server operator.
|
||||
driverAd:
|
||||
title: "Advertisement"
|
||||
html: |
|
||||
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://example.com/ad.png" alt="Advertisement" style="display:block;width:100%;height:auto;">
|
||||
</a>
|
||||
|
||||
# Optional passive fleet telemetry, history, and daily reporting. The collector
|
||||
# observes existing server events and rover sensor frames but never participates
|
||||
# in command, assignment, docking, or safety decisions.
|
||||
fleetReports:
|
||||
enabled: false
|
||||
retention:
|
||||
# Zero retains evidence indefinitely. Set explicit day counts on servers
|
||||
# that prefer bounded storage over complete long-term history.
|
||||
detailedDays: 0
|
||||
minuteSamplesDays: 0
|
||||
battery:
|
||||
enabled: true
|
||||
maximumIntegrationGapSeconds: 5
|
||||
minimumCapacityTestDepthPercent: 60
|
||||
discord:
|
||||
enabled: true
|
||||
sendAt: "08:00"
|
||||
timezone: "America/New_York"
|
||||
privacy:
|
||||
retainChatBodies: true
|
||||
|
||||
@@ -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>
|
||||
@@ -58,10 +58,16 @@
|
||||
"entityId": "printer",
|
||||
"label": "Medical thermal printer"
|
||||
},
|
||||
"o008": {
|
||||
"o008": {
|
||||
"type": "object",
|
||||
"entityId": "brick",
|
||||
"label": "BRICK"
|
||||
},
|
||||
"o009": {
|
||||
"type": "object",
|
||||
"entityId": "gbc",
|
||||
"label": "Green Ball Container",
|
||||
"wikiUrl": "https://wiki.otter.land/Room%20Objects/Green%20Ball%20Container"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,13 @@ require('./src/services/overseerControlService');
|
||||
require('./src/services/globalObjectiveService');
|
||||
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');
|
||||
require('./src/services/interInstanceService');
|
||||
require('./src/services/humanAlertButtonService');
|
||||
require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
@@ -44,8 +47,16 @@ 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.
|
||||
require('./src/services/replayDeliveryService');
|
||||
require('./src/services/discordBotService');
|
||||
require('./src/services/httpServer');
|
||||
|
||||
+181
-37
@@ -2,16 +2,20 @@
|
||||
set -euo pipefail
|
||||
|
||||
MEDIAMTX_VERSION="1.15.3"
|
||||
NEOLINK_VERSION="0.6.2"
|
||||
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
||||
NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/download/v${NEOLINK_VERSION}"
|
||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
NEOLINK_BIN="/usr/local/bin/neolink"
|
||||
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
||||
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
|
||||
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -26,9 +30,83 @@ 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"
|
||||
|
||||
install_google_tts_assets() {
|
||||
local asset_dir="/opt/roverd/googletts"
|
||||
local voice_dir="${asset_dir}/en-us-x-multi-r30"
|
||||
local dist_url="https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-26.5.tar.xz"
|
||||
local lib_member=""
|
||||
local arch_name
|
||||
arch_name=$(uname -m)
|
||||
|
||||
# The PTZ camera is not a rover, so Google speech must be synthesized on the
|
||||
# server before neolink sends a WAV to the camera. These assets are the same
|
||||
# offline ChromeOS local TTS assets that rover installers already use; keeping
|
||||
# the layout identical lets the server helper and rover daemon share loader
|
||||
# assumptions.
|
||||
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
|
||||
echo " Google TTS assets already installed"
|
||||
return
|
||||
fi
|
||||
|
||||
case "$arch_name" in
|
||||
x86_64|amd64)
|
||||
lib_member="libchrometts_x86_64.so"
|
||||
;;
|
||||
aarch64)
|
||||
lib_member="libchrometts_arm64.so"
|
||||
;;
|
||||
armv7l|armv6l)
|
||||
lib_member="libchrometts_armv7.so"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Google TTS architecture: $arch_name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo " Installing Google TTS assets -> $asset_dir"
|
||||
curl -L -o "$tmpdir/googletts-26.5.tar.xz" "$dist_url"
|
||||
tar -xf "$tmpdir/googletts-26.5.tar.xz" -C "$tmpdir" en-us-x-multi.zvoice "$lib_member"
|
||||
install -d -o root -g root -m 0755 "$asset_dir"
|
||||
install -o root -g root -m 0644 "$tmpdir/$lib_member" "${asset_dir}/libchrometts.so"
|
||||
rm -rf "$voice_dir"
|
||||
install -d -o root -g root -m 0755 "$voice_dir"
|
||||
# The .zvoice member is a zip archive inside the outer tar.xz. Match the
|
||||
# rover installers here; trying to untar it fails after the large download.
|
||||
unzip -q "$tmpdir/en-us-x-multi.zvoice" -d "$voice_dir"
|
||||
chown -R root:root "$asset_dir"
|
||||
find "$asset_dir" -type d -exec chmod 0755 {} +
|
||||
find "$asset_dir" -type f -exec chmod 0644 {} +
|
||||
}
|
||||
|
||||
verify_google_tts_helper() {
|
||||
local smoke_wav="$tmpdir/chromegtts-smoke.wav"
|
||||
|
||||
echo " Verifying Chrome Google TTS helper"
|
||||
# libchrometts is a native ChromeOS library. Rendering one tiny WAV during
|
||||
# install catches missing shared-library dependencies, bad asset extraction,
|
||||
# and helper path mistakes before multirover.service starts accepting PTZ TTS
|
||||
# requests that would fail later in logs.
|
||||
if ! "$CHROMEGTTS_WAV_BIN" \
|
||||
--text "test" \
|
||||
--voice tpf \
|
||||
--pitch 1 \
|
||||
--speed 1 \
|
||||
--output "$smoke_wav"; then
|
||||
echo "Chrome Google TTS helper smoke render failed." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -s "$smoke_wav" ]]; then
|
||||
echo "Chrome Google TTS helper did not create a WAV file." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/6] Installing dependencies..."
|
||||
# The Kinect tooling uses a native libfreenect worker/probe rather than a
|
||||
@@ -40,12 +118,28 @@ dnf install -y \
|
||||
npm \
|
||||
curl \
|
||||
tar \
|
||||
unzip \
|
||||
xz \
|
||||
gcc-c++ \
|
||||
make \
|
||||
pkgconf-pkg-config \
|
||||
flite \
|
||||
espeak \
|
||||
python3 \
|
||||
libcxx \
|
||||
libcxxabi \
|
||||
gstreamer1 \
|
||||
gstreamer1-plugins-base \
|
||||
gstreamer1-plugins-good \
|
||||
gstreamer1-plugins-bad-free \
|
||||
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"
|
||||
@@ -62,6 +156,13 @@ EOF
|
||||
chmod 644 "$KINECT_UDEV_RULE"
|
||||
udevadm control --reload-rules
|
||||
|
||||
if [[ ! -f "$CHROMEGTTS_WAV_TEMPLATE" ]]; then
|
||||
echo "Chrome Google TTS WAV helper missing at $CHROMEGTTS_WAV_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Installing Chrome Google TTS WAV helper -> $CHROMEGTTS_WAV_BIN"
|
||||
install -m 0755 "$CHROMEGTTS_WAV_TEMPLATE" "$CHROMEGTTS_WAV_BIN"
|
||||
|
||||
echo "[2/6] Installing Node production deps..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||
|
||||
@@ -70,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
|
||||
|
||||
@@ -83,12 +215,15 @@ arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
||||
neolink_pkg="neolink_linux_x86_64_ubuntu.zip"
|
||||
;;
|
||||
aarch64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
||||
neolink_pkg="neolink_linux_arm64.zip"
|
||||
;;
|
||||
armv7l)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
||||
neolink_pkg="neolink_linux_armhf.zip"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch" >&2
|
||||
@@ -101,51 +236,60 @@ curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
||||
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
||||
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
||||
|
||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||
echo " Installing neolink ${NEOLINK_VERSION} -> $NEOLINK_BIN"
|
||||
curl -L "$NEOLINK_BASE_URL/$neolink_pkg" -o "$tmpdir/neolink.zip"
|
||||
unzip -q "$tmpdir/neolink.zip" -d "$tmpdir/neolink"
|
||||
neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink -perm /111 | head -n 1)
|
||||
if [[ -z "$neolink_extracted" ]]; then
|
||||
neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink | head -n 1)
|
||||
fi
|
||||
if [[ -z "$neolink_extracted" ]]; then
|
||||
echo "neolink binary missing from $neolink_pkg" >&2
|
||||
exit 1
|
||||
fi
|
||||
install -m 0755 "$neolink_extracted" "$NEOLINK_BIN"
|
||||
install_google_tts_assets
|
||||
if ! verify_google_tts_helper; then
|
||||
echo " Reinstalling Google TTS assets after failed verification"
|
||||
rm -rf /opt/roverd/googletts
|
||||
install_google_tts_assets
|
||||
verify_google_tts_helper
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
||||
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||
rm -f "$MEDIAMTX_CONFIG"
|
||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
||||
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
|
||||
@@ -155,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
|
||||
@@ -164,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,10 +16,24 @@ esac
|
||||
|
||||
mkdir -p "$SNAP_DIR"
|
||||
|
||||
FILTER="fps=1"
|
||||
QUALITY="6"
|
||||
|
||||
case "$PATH_NAME" in
|
||||
ptz-camera)
|
||||
# PTZ snapshots are shown to non-operators specifically to avoid sending the
|
||||
# full live video stream. The PTZ publisher is full-resolution 16:9 video,
|
||||
# so resize the JPEGs at the snapshot writer boundary before Node ever reads
|
||||
# and fans them out over Socket.IO.
|
||||
FILTER="fps=1,scale=480:-2"
|
||||
QUALITY="10"
|
||||
;;
|
||||
esac
|
||||
|
||||
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
|
||||
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
||||
-an \
|
||||
-vf fps=1 \
|
||||
-q:v 6 \
|
||||
-vf "$FILTER" \
|
||||
-q:v "$QUALITY" \
|
||||
-update 1 \
|
||||
"${SNAP_DIR}/${PATH_NAME}.jpg"
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
"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",
|
||||
"onvif": "^0.8.1",
|
||||
"reolink-nvr-api": "^0.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.7.5",
|
||||
"uuid": "^9.0.1",
|
||||
|
||||
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-DRqsCa1W.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css">
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-DsdqxXyd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0J6fxLEg.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');
|
||||
@@ -0,0 +1,148 @@
|
||||
// Bandwidth Savings Helper
|
||||
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
|
||||
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
|
||||
// making individual services know raw YAML defaults or legacy config shapes.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
|
||||
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
|
||||
const VIDEO_MODES = new Set(['snapshots', 'live']);
|
||||
const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'admin']);
|
||||
|
||||
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||
multiTabProtection: 'verifiedOnly',
|
||||
pauseHiddenRoverVideo: false,
|
||||
nonTurnVideo: Object.freeze({
|
||||
mode: 'snapshots',
|
||||
userThreshold: 0,
|
||||
}),
|
||||
externalSpectatorVideo: 'snapshots',
|
||||
externalSpectatorAccess: 'on',
|
||||
});
|
||||
|
||||
function normalizeEnum(value, allowed, fallback) {
|
||||
/*
|
||||
Config files are hand-edited on the server, so a typo should not crash the
|
||||
process or silently broaden access. Each option falls back to the current
|
||||
conservative behavior unless it exactly matches a known value.
|
||||
*/
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
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 {
|
||||
multiTabProtection: normalizeEnum(
|
||||
raw.multiTabProtection,
|
||||
MULTI_TAB_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
||||
),
|
||||
pauseHiddenRoverVideo: normalizeBoolean(
|
||||
raw.pauseHiddenRoverVideo,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.pauseHiddenRoverVideo,
|
||||
),
|
||||
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
|
||||
externalSpectatorVideo: normalizeEnum(
|
||||
raw.externalSpectatorVideo,
|
||||
VIDEO_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorVideo,
|
||||
),
|
||||
externalSpectatorAccess: normalizeEnum(
|
||||
raw.externalSpectatorAccess,
|
||||
EXTERNAL_SPECTATOR_ACCESS_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorAccess,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getBandwidthSavingsPolicy() {
|
||||
/*
|
||||
loadConfig() is cached by configLoader, so rebuilding this small object per
|
||||
caller is cheap while still letting tests pass explicit config objects into
|
||||
buildBandwidthSavingsPolicy().
|
||||
*/
|
||||
return buildBandwidthSavingsPolicy(loadConfig());
|
||||
}
|
||||
|
||||
function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } = {}) {
|
||||
const { multiTabProtection } = getBandwidthSavingsPolicy();
|
||||
if (multiTabProtection === 'allowed') return false;
|
||||
if (multiTabProtection === 'notAllowed') return true;
|
||||
/*
|
||||
verifiedOnly preserves the old behavior: trusted users can run multiple
|
||||
driver tabs for operations/testing, while anonymous users are limited to one
|
||||
active driver surface for fairness and bandwidth.
|
||||
*/
|
||||
return !isVerified && !isAdmin;
|
||||
}
|
||||
|
||||
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() {
|
||||
return getBandwidthSavingsPolicy().externalSpectatorVideo === 'snapshots';
|
||||
}
|
||||
|
||||
function canUseExternalSpectatorAccess({
|
||||
isLocal = false,
|
||||
isAdmin = false,
|
||||
isVerified = false,
|
||||
hasGrant = false,
|
||||
} = {}) {
|
||||
/*
|
||||
Local/LAN spectators are not the upload-bandwidth problem, and admins need
|
||||
to retain access for maintenance. The configured external mode only applies
|
||||
to ordinary non-local spectator sockets.
|
||||
*/
|
||||
if (isLocal || isAdmin) return true;
|
||||
const { externalSpectatorAccess } = getBandwidthSavingsPolicy();
|
||||
if (externalSpectatorAccess === 'off') return false;
|
||||
if (externalSpectatorAccess === 'verifiedOnly') return Boolean(isVerified);
|
||||
if (externalSpectatorAccess === 'admin') return Boolean(hasGrant);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_BANDWIDTH_SAVINGS,
|
||||
buildBandwidthSavingsPolicy,
|
||||
getBandwidthSavingsPolicy,
|
||||
shouldEnforceSingleDriverTab,
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||
canUseExternalSpectatorAccess,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
// Feature Flags Helper
|
||||
// Purpose: Normalizes optional server feature availability from config in one place.
|
||||
// Scope: Keeps hardware/social visibility decisions out of individual UI panels and service callers.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
|
||||
function asBoolean(value, fallback = false) {
|
||||
/*
|
||||
Optional feature config is intentionally explicit. A missing `enabled` flag
|
||||
means "off" for specialty hardware, which makes a fresh public install a
|
||||
rover-only server until the operator opts into extra devices.
|
||||
*/
|
||||
if (typeof value === 'boolean') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function asTrimmedString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getRoomCameraEntries(config) {
|
||||
const raw = config.roomCameras;
|
||||
/*
|
||||
The public config uses `{ enabled, cameras }` so the feature gate is obvious.
|
||||
Accepting the old array shape here keeps the rest of the server from needing
|
||||
to know which shape the local config file currently uses.
|
||||
*/
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && typeof raw === 'object' && Array.isArray(raw.cameras)) return raw.cameras;
|
||||
return [];
|
||||
}
|
||||
|
||||
function getConfiguredSocials(config) {
|
||||
/*
|
||||
Social links have an explicit feature switch. Entries under `links` are just
|
||||
available data; they do not enable the Links panel by existing.
|
||||
*/
|
||||
const links = config.socials && typeof config.socials === 'object' ? config.socials.links : [];
|
||||
return Array.isArray(links)
|
||||
? links.filter((entry) => asTrimmedString(entry?.url))
|
||||
: [];
|
||||
}
|
||||
|
||||
function buildFeatureFlags(config = loadConfig()) {
|
||||
const homeAssistantConfig = config.homeAssistant || {};
|
||||
const roomCameraConfig = config.roomCameras || {};
|
||||
const kinectConfig = config.kinect || {};
|
||||
const buttonBoxConfig = config.buttonBox || {};
|
||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||
const balanceBoardConfig = config.balanceBoard || {};
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const ptzCameraConfig = config.ptzCamera || {};
|
||||
const discordConfig = config.discord || {};
|
||||
const fleetReportsConfig = config.fleetReports || {};
|
||||
const homeAssistant = Boolean(
|
||||
asBoolean(homeAssistantConfig.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.url) &&
|
||||
asTrimmedString(homeAssistantConfig.token),
|
||||
);
|
||||
const roomCameraEntries = getRoomCameraEntries(config);
|
||||
const roomCamerasEnabled = Array.isArray(config.roomCameras)
|
||||
? false
|
||||
: asBoolean(roomCameraConfig.enabled);
|
||||
const barcodeScanner = asBoolean(barcodeScannerConfig.enabled);
|
||||
|
||||
return {
|
||||
homeAssistant,
|
||||
roomCameras: Boolean(roomCamerasEnabled && roomCameraEntries.length),
|
||||
kinect: asBoolean(kinectConfig.enabled),
|
||||
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||
barcodeScanner,
|
||||
// The worker performs its own runtime availability reporting. Advertising
|
||||
// the feature from the explicit config switch lets the UI show useful
|
||||
// commissioning and hardware-error states even before a board is paired.
|
||||
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||
lift: Boolean(
|
||||
homeAssistant &&
|
||||
asBoolean(homeAssistantConfig.lift?.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.lift?.upSwitch) &&
|
||||
asTrimmedString(homeAssistantConfig.lift?.downSwitch),
|
||||
),
|
||||
neato: Boolean(
|
||||
homeAssistant &&
|
||||
asBoolean(homeAssistantConfig.neato?.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.neato?.device),
|
||||
),
|
||||
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
|
||||
interInstance: asBoolean(interInstanceConfig.enabled),
|
||||
ptzCamera: Boolean(
|
||||
asBoolean(ptzCameraConfig.enabled) &&
|
||||
asTrimmedString(ptzCameraConfig.host) &&
|
||||
asTrimmedString(ptzCameraConfig.username) &&
|
||||
asTrimmedString(ptzCameraConfig.password),
|
||||
),
|
||||
/*
|
||||
Discord is an optional transport, not a prerequisite for chat commands.
|
||||
Requiring both the explicit switch and a token prevents an old token from
|
||||
silently enabling external connections on installations that have chosen
|
||||
to run without the integration.
|
||||
*/
|
||||
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
||||
// Fleet reports are deliberately controlled by one explicit server switch.
|
||||
// Storage contents, Discord availability, or historical database files must
|
||||
// never cause the reporting UI to appear on an installation that has not
|
||||
// opted into the collector.
|
||||
fleetReports: asBoolean(fleetReportsConfig.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function getFeatureFlags() {
|
||||
return buildFeatureFlags(loadConfig());
|
||||
}
|
||||
|
||||
function isFeatureEnabled(featureName) {
|
||||
return Boolean(getFeatureFlags()[featureName]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildFeatureFlags,
|
||||
getFeatureFlags,
|
||||
isFeatureEnabled,
|
||||
getRoomCameraEntries,
|
||||
getConfiguredSocials,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
// Reward Definition: Darkness
|
||||
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
||||
const DURATION_MS = 15 * 60 * 1000;
|
||||
const LIGHT_ENFORCE_TICK_MS = 3000;
|
||||
|
||||
let activeTimer = null;
|
||||
let enforceLightsTimer = null;
|
||||
let headlightLockUntil = 0;
|
||||
|
||||
function isHeadlightBlocked() {
|
||||
@@ -16,10 +14,6 @@ function clearTimers() {
|
||||
clearTimeout(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
if (enforceLightsTimer) {
|
||||
clearInterval(enforceLightsTimer);
|
||||
enforceLightsTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function forceAllLightsOff(ctx) {
|
||||
@@ -55,7 +49,6 @@ async function stopDarkness(ctx, effect = {}) {
|
||||
if (prevLockState === 'on' || prevLockState === 'off') {
|
||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||
source: 'buttonbox:darknessRestore',
|
||||
forceApply: true,
|
||||
targetState: prevLockState,
|
||||
});
|
||||
} else {
|
||||
@@ -92,7 +85,6 @@ async function startDarkness(ctx, effect) {
|
||||
try {
|
||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||
source: 'buttonbox:darkness',
|
||||
forceApply: true,
|
||||
targetState: 'off',
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -100,11 +92,13 @@ async function startDarkness(ctx, effect) {
|
||||
}
|
||||
ctx.saveEffect('darkness', effect);
|
||||
|
||||
enforceLightsTimer = setInterval(() => {
|
||||
forceAllLightsOff(ctx).catch((err) => {
|
||||
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
|
||||
});
|
||||
}, LIGHT_ENFORCE_TICK_MS);
|
||||
/*
|
||||
Darkness locks the room-light policy off and performs the initial off
|
||||
command through setHomeAssistantLightsLockedOn above. It deliberately does
|
||||
not keep a polling interval that re-forces Home Assistant entities off:
|
||||
after the lock is established, out-of-band manual controls must remain able
|
||||
to change individual room lights without the server fighting them.
|
||||
*/
|
||||
|
||||
activeTimer = setTimeout(() => {
|
||||
stopDarkness(ctx, effect).catch((err) => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// Reward Definition: Light Strobe
|
||||
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
||||
const STROBE_MS = 30 * 1000;
|
||||
const TICK_MS = 500;
|
||||
const STROBE_MS = 60 * 1000;
|
||||
const TICK_MS = 1500;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
@@ -44,7 +44,7 @@ module.exports = {
|
||||
goal: 400,
|
||||
async run(ctx) {
|
||||
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
||||
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' });
|
||||
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 60 seconds.' });
|
||||
},
|
||||
async recover(ctx, effect) {
|
||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||
|
||||
@@ -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,12 +7,44 @@ 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
|
||||
const waiting = new Set(); // socketIds waiting for placement
|
||||
const assignmentEvents = new EventEmitter();
|
||||
|
||||
function normalizeRemovalMessage(message, fallback) {
|
||||
/*
|
||||
Removal notices are shown directly in the driving UI, so the server trims
|
||||
caller-provided text before emitting it. Keeping this normalization close to
|
||||
the release helper makes every forced-removal path use the same readable
|
||||
fallback instead of forcing each caller to duplicate defensive string checks.
|
||||
*/
|
||||
const clean = String(message || '').trim();
|
||||
return clean || fallback;
|
||||
}
|
||||
|
||||
function emitRemovalNotice(socket, notice = {}) {
|
||||
/*
|
||||
The browser may lose its rover assignment in the same server tick that the
|
||||
reason is generated. Sending a dedicated event before releasing control lets
|
||||
the client preserve the explanation even after normal session sync says the
|
||||
user no longer has an assigned rover.
|
||||
*/
|
||||
if (!socket) return;
|
||||
const roverId = String(notice.roverId || '').trim() || null;
|
||||
const message = normalizeRemovalMessage(notice.message, 'You were removed from the rover.');
|
||||
socket.emit('session:roverRemovalNotice', {
|
||||
roverId,
|
||||
title: normalizeRemovalMessage(notice.title, 'Removed from rover'),
|
||||
message,
|
||||
reasonCode: String(notice.reasonCode || 'removed').trim() || 'removed',
|
||||
actor: notice.actor || null,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socketRefs.set(socket.id, socket);
|
||||
socket.on('disconnect', () => {
|
||||
@@ -65,8 +97,20 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ action }) => {
|
||||
if (action === 'removed' || action === 'upsert') {
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -176,6 +220,18 @@ function forceRelease(roverId, socketId) {
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
|
||||
function forceReleaseWithNotice(roverId, socketId, notice = {}) {
|
||||
/*
|
||||
This is the one public path for moderation-style removals. It deliberately
|
||||
emits the explanation before forceRelease mutates assignment state, because
|
||||
session sync listeners can update the UI immediately after the release and
|
||||
the UI needs the reason to already be in local state.
|
||||
*/
|
||||
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
|
||||
emitRemovalNotice(socket, { ...notice, roverId });
|
||||
forceRelease(roverId, socketId);
|
||||
}
|
||||
|
||||
function pickRover(socket, options = {}) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
@@ -198,35 +254,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;
|
||||
}
|
||||
@@ -266,6 +304,7 @@ module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
forceReleaseWithNotice,
|
||||
rerollAssignments,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user