mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Compare commits
788
Commits
rpi-zero
...
privaterover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af21846c8 | ||
|
|
e0167939d7 | ||
|
|
ebb400adc4 | ||
|
|
463bd89dd8 | ||
|
|
e311e67fc9 | ||
|
|
20227cd0f8 | ||
|
|
ad1b6b48f8 | ||
|
|
626b48f777 | ||
|
|
f112f74db0 | ||
|
|
cc1e887705 | ||
|
|
81b0638d6f | ||
|
|
aeb4b29b88 | ||
|
|
54f8684169 | ||
|
|
36a6c07f95 | ||
|
|
d6da071fde | ||
|
|
9b1251c16d | ||
|
|
e3b428f9ab | ||
|
|
cb97e73987 | ||
|
|
1b7885e74b | ||
|
|
4542c9e6ae | ||
|
|
a58685fa91 | ||
|
|
140ebb5996 | ||
|
|
76c17abdb6 | ||
|
|
f364e6ac60 | ||
|
|
57532f53d0 | ||
|
|
38b76559f4 | ||
|
|
1eb1a33e88 | ||
|
|
ab9d388392 | ||
|
|
cc6e1fde56 | ||
|
|
e2d9a1759b | ||
|
|
dbfab6aa26 | ||
|
|
c729631104 | ||
|
|
7ad3dc7015 | ||
|
|
5b8704ccb3 | ||
|
|
73ef6261ed | ||
|
|
93204ec1f1 | ||
|
|
618fd3e4de | ||
|
|
f5d7d2d82c | ||
|
|
7934e1faf5 | ||
|
|
f63db3fdd7 | ||
|
|
0e4954fadf | ||
|
|
c44632ed4d | ||
|
|
9f1efc6b91 | ||
|
|
ad664f2d32 | ||
|
|
fec9782e3b | ||
|
|
84eaab3c9c | ||
|
|
aaa93847e1 | ||
|
|
4aa53e8f80 | ||
|
|
74a758ab7c | ||
|
|
182a49903f | ||
|
|
941b9889c9 | ||
|
|
6d40ec070e | ||
|
|
169ccdbcf8 | ||
|
|
a2098f4dfc | ||
|
|
3070153052 | ||
|
|
167582b9b0 | ||
|
|
95972af558 | ||
|
|
2ea222355f | ||
|
|
529ae62ad0 | ||
|
|
3c6de7c040 | ||
|
|
3007bab54d | ||
|
|
38e23b0adf | ||
|
|
181fe436ff | ||
|
|
b7f5967c9f | ||
|
|
e53fb5cb92 | ||
|
|
325fdc05ea | ||
|
|
c4532f5825 | ||
|
|
d952b7ad90 | ||
|
|
13da673495 | ||
|
|
30d0f0c686 | ||
|
|
7a21a53ea7 | ||
|
|
4fd7f15292 | ||
|
|
1350d7c395 | ||
|
|
632d474e61 | ||
|
|
84a0b02c18 | ||
|
|
a44d4aed03 | ||
|
|
fafe167571 | ||
|
|
e73b094c21 | ||
|
|
23825d51d2 | ||
|
|
3cf3228b6f | ||
|
|
99b10f7310 | ||
|
|
ae52dc8335 | ||
|
|
7942e61a40 | ||
|
|
82fe62b835 | ||
|
|
12ce7c1344 | ||
|
|
469c52cf7f | ||
|
|
1ac6c17a8e | ||
|
|
231245a9fd | ||
|
|
e30f7272cd | ||
|
|
08fc278494 | ||
|
|
32f5cc29d8 | ||
|
|
985a2f966a | ||
|
|
e9e75b65ef | ||
|
|
bbd088f9e1 | ||
|
|
4284014578 | ||
|
|
d34c107b1a | ||
|
|
5a575aeab4 | ||
|
|
4d953f3cad | ||
|
|
09c7da715e | ||
|
|
9d58983f59 | ||
|
|
7afc79add0 | ||
|
|
5d3652468f | ||
|
|
04bedc7e5c | ||
|
|
a687d191c1 | ||
|
|
0d3b3b0995 | ||
|
|
65094ea5dc | ||
|
|
643557afc6 | ||
|
|
5a7ce8ae69 | ||
|
|
7e233e48b2 | ||
|
|
0a65c43eb0 | ||
|
|
30671332af | ||
|
|
20e89839fe | ||
|
|
18c0d0fc8d | ||
|
|
b22ba216db | ||
|
|
ad31749813 | ||
|
|
9e3bce6ec3 | ||
|
|
6c3ae17aeb | ||
|
|
965a27f3f2 | ||
|
|
2198c64e00 | ||
|
|
1b871a9ad1 | ||
|
|
27e36206c2 | ||
|
|
6312f19e3c | ||
|
|
112391cbd1 | ||
|
|
1f05a7b79c | ||
|
|
c5907d8487 | ||
|
|
47f3e7e654 | ||
|
|
679e901ec6 | ||
|
|
4af0deb1dd | ||
|
|
d38503d19c | ||
|
|
f7170c6b49 | ||
|
|
62994d07cd | ||
|
|
0af84084c9 | ||
|
|
8c24c84e59 | ||
|
|
9647a71d49 | ||
|
|
315ce61ef7 | ||
|
|
0f31a662ee | ||
|
|
e44d0f7b93 | ||
|
|
49fc19f044 | ||
|
|
bb55e90464 | ||
|
|
93971f37fe | ||
|
|
46de9b71f3 | ||
|
|
1d97756aef | ||
|
|
42c885af28 | ||
|
|
da79a62db6 | ||
|
|
d3b35506d9 | ||
|
|
12a787b7c6 | ||
|
|
bf7d54c4f9 | ||
|
|
abd337f723 | ||
|
|
d3a17608af | ||
|
|
ed93bbf4d6 | ||
|
|
d2904f067c | ||
|
|
a76062b873 | ||
|
|
32ec8e1aa3 | ||
|
|
657189e979 | ||
|
|
cc92174d79 | ||
|
|
eefe6f01bb | ||
|
|
4ccc7dfae6 | ||
|
|
8313f5da66 | ||
|
|
d5978c5e3e | ||
|
|
f1c6ab44ce | ||
|
|
d165f69b6f | ||
|
|
438fff0902 | ||
|
|
7e772ab607 | ||
|
|
40e21d4ac5 | ||
|
|
eb52c8c6ac | ||
|
|
1e1a6b6b01 | ||
|
|
dcf7208c08 | ||
|
|
0c3b3aa711 | ||
|
|
42c99410f4 | ||
|
|
1ac46f4a03 | ||
|
|
8ea0f7be70 | ||
|
|
0c992e2457 | ||
|
|
1842177a6d | ||
|
|
c9a19d46f7 | ||
|
|
cf0c12cff7 | ||
|
|
b4c122d914 | ||
|
|
3cfb2683ef | ||
|
|
cb5e5ae755 | ||
|
|
d3852dd86b | ||
|
|
7c1b290df3 | ||
|
|
133b433255 | ||
|
|
d64e99b674 | ||
|
|
79da247dbe | ||
|
|
01cad4515f | ||
|
|
dc92785f19 | ||
|
|
00d1edd11b | ||
|
|
c24ae71e63 | ||
|
|
92c9ae4834 | ||
|
|
5916520b1a | ||
|
|
7eeb9d13e4 | ||
|
|
3fdb1c05e5 | ||
|
|
090d9d6982 | ||
|
|
b7d636c70a | ||
|
|
92ab763b27 | ||
|
|
43dc3df901 | ||
|
|
3997127e5e | ||
|
|
c60cae75a4 | ||
|
|
4f6530badf | ||
|
|
fd06f23de4 | ||
|
|
ca3d95319d | ||
|
|
bdb4f41e93 | ||
|
|
a5f33c0a92 | ||
|
|
a4d6f55576 | ||
|
|
7216d9ba51 | ||
|
|
be4c6260ae | ||
|
|
5e94fc7283 | ||
|
|
bf05d07092 | ||
|
|
c5ae78dcab | ||
|
|
33d329e2de | ||
|
|
690f38972f | ||
|
|
77b5484381 | ||
|
|
ccf0eeb1aa | ||
|
|
d87ec55802 | ||
|
|
2cc0ce962f | ||
|
|
d2722e40cc | ||
|
|
ae81f8914f | ||
|
|
7e850dab22 | ||
|
|
f3cc73f178 | ||
|
|
eea1774549 | ||
|
|
7dbb657dcd | ||
|
|
48e4ddd090 | ||
|
|
f7677a443a | ||
|
|
482e7c1e6b | ||
|
|
b275d5a08c | ||
|
|
2062b4888c | ||
|
|
2671247347 | ||
|
|
3f34196418 | ||
|
|
689d914b4e | ||
|
|
b6b1bee1dd | ||
|
|
522ec335bf | ||
|
|
b730c98704 | ||
|
|
d7833dbba9 | ||
|
|
536e4fe32e | ||
|
|
2494f6e276 | ||
|
|
2dda137855 | ||
|
|
8341328b51 | ||
|
|
b718de1e88 | ||
|
|
ec4e969340 | ||
|
|
dd55514cb4 | ||
|
|
ba4cf80f41 | ||
|
|
622cd2875c | ||
|
|
98ca2e0626 | ||
|
|
dbc5111c53 | ||
|
|
c30325afc0 | ||
|
|
12512b8856 | ||
|
|
bb3197ca67 | ||
|
|
0b1551cabf | ||
|
|
69198a5556 | ||
|
|
f3a5e7bf77 | ||
|
|
d2acd9f19a | ||
|
|
94c3939671 | ||
|
|
f7de6d52be | ||
|
|
fff07ffbe6 | ||
|
|
9c046cc3b1 | ||
|
|
c465c03e34 | ||
|
|
99a1c5418d | ||
|
|
2fc9becc88 | ||
|
|
fe94c38963 | ||
|
|
55c72e0d9b | ||
|
|
a237d32d58 | ||
|
|
213630cb68 | ||
|
|
eb85b85b8f | ||
|
|
0600b8552c | ||
|
|
662efb300b | ||
|
|
e2f94720ec | ||
|
|
bcfb2946cf | ||
|
|
02603aa664 | ||
|
|
c67c3dc761 | ||
|
|
26d7335c9a | ||
|
|
a99e6e5286 | ||
|
|
d93d78cb2b | ||
|
|
e01878dd4e | ||
|
|
598f353fe5 | ||
|
|
7249621ebe | ||
|
|
6009056edd | ||
|
|
fd7b858395 | ||
|
|
1fd0de5b1e | ||
|
|
720a010973 | ||
|
|
2564a451b5 | ||
|
|
8326898403 | ||
|
|
e0edbcfb28 | ||
|
|
4f60248bc6 | ||
|
|
629d979b67 | ||
|
|
59c19f1da9 | ||
|
|
b9a007bd48 | ||
|
|
850ac3ec15 | ||
|
|
87dd9a24a0 | ||
|
|
bdcb488988 | ||
|
|
9e6053d5c0 | ||
|
|
4d699e1c4c | ||
|
|
5e7581c82d | ||
|
|
f7e4269959 | ||
|
|
f3f1db1398 | ||
|
|
133c061ee9 | ||
|
|
6859a6c048 | ||
|
|
465bddb9dd | ||
|
|
c7a639a568 | ||
|
|
df952b27e0 | ||
|
|
36f28a9708 | ||
|
|
2975d394b6 | ||
|
|
befcf7ad5a | ||
|
|
27ae3bcd1e | ||
|
|
90fd0262c7 | ||
|
|
64a9dd3583 | ||
|
|
972942e497 | ||
|
|
e34d838d41 | ||
|
|
705b38ea7b | ||
|
|
f129449d5a | ||
|
|
d4ce6e9092 | ||
|
|
2252527d89 | ||
|
|
d184590bf2 | ||
|
|
5b9167ab2a | ||
|
|
655fed4326 | ||
|
|
6c01087738 | ||
|
|
d272a33dc2 | ||
|
|
8212a14ea8 | ||
|
|
9622129c73 | ||
|
|
37927d82af | ||
|
|
23fe03ca54 | ||
|
|
899e647a9d | ||
|
|
cab4dc716d | ||
|
|
5caf26bb2d | ||
|
|
e666b4c195 | ||
|
|
c2ca951fe8 | ||
|
|
399c077c7a | ||
|
|
b8a4a18455 | ||
|
|
3aedaa26c8 | ||
|
|
a6e7e683ba | ||
|
|
27c46b44b2 | ||
|
|
5fdcb233f8 | ||
|
|
b7a79b03c9 | ||
|
|
fb00467106 | ||
|
|
32f9a2befc | ||
|
|
e10e2f9a0b | ||
|
|
8b0928f428 | ||
|
|
f3e13f6ab7 | ||
|
|
4ab6bad1b8 | ||
|
|
b4754b1c54 | ||
|
|
9aad7ff775 | ||
|
|
c3a9ea7281 | ||
|
|
ba652005f5 | ||
|
|
a49a090026 | ||
|
|
c265121335 | ||
|
|
56b89b5dff | ||
|
|
00f97c8005 | ||
|
|
d8ce85be60 | ||
|
|
3b67e81451 | ||
|
|
870fdc63be | ||
|
|
5ae0b0ca79 | ||
|
|
778543dd5c | ||
|
|
29154c56e6 | ||
|
|
50d3977814 | ||
|
|
acebcd7307 | ||
|
|
55782dabf0 | ||
|
|
79c118bc88 | ||
|
|
173d6fffa7 | ||
|
|
cf072ef0e0 | ||
|
|
30ebd05a97 | ||
|
|
d3f2f15370 | ||
|
|
008cf96c7e | ||
|
|
981262a0ae | ||
|
|
b884f23b13 | ||
|
|
dc9f93ca60 | ||
|
|
4005ced6f9 | ||
|
|
f7dd4f7b89 | ||
|
|
4b7c26a7b4 | ||
|
|
7335cefffb | ||
|
|
aba01161c9 | ||
|
|
b4edf72c9a | ||
|
|
066f976cf4 | ||
|
|
2f726035c9 | ||
|
|
5b8fb30e71 | ||
|
|
eb4e1621c0 | ||
|
|
6d55073bfb | ||
|
|
242fe461d9 | ||
|
|
fcc71426ee | ||
|
|
4e30eb1d52 | ||
|
|
e0cadb493f | ||
|
|
bbb4014f61 | ||
|
|
3c1c75a2ec | ||
|
|
c4dda507e2 | ||
|
|
56ed528412 | ||
|
|
258f244194 | ||
|
|
ec0e1564d7 | ||
|
|
4f2907a105 | ||
|
|
5829cd370c | ||
|
|
5ef6b640c6 | ||
|
|
ff638d84d2 | ||
|
|
061d4d4689 | ||
|
|
3cc12b76b9 | ||
|
|
1241c867a5 | ||
|
|
8b3806bcd5 | ||
|
|
eeb4e65df7 | ||
|
|
b714bd0fed | ||
|
|
a989f2029a | ||
|
|
ca1f7ef54a | ||
|
|
87db66ac1f | ||
|
|
8da2196ee0 | ||
|
|
49d822d468 | ||
|
|
fd765ec1da | ||
|
|
086d3cdee2 | ||
|
|
83f6f7eac1 | ||
|
|
3df303bc09 | ||
|
|
94a779581b | ||
|
|
c87767d3ee | ||
|
|
6ca30adcaf | ||
|
|
588305184e | ||
|
|
293b73dc20 | ||
|
|
d4440a4f05 | ||
|
|
3349120c0d | ||
|
|
bd862bf3f2 | ||
|
|
1a6ca5ce73 | ||
|
|
9ea29f8e36 | ||
|
|
b9d7a62b0e | ||
|
|
6301058f87 | ||
|
|
8e1de78cc7 | ||
|
|
0b564c7ee7 | ||
|
|
195ecb7d61 | ||
|
|
108de883a0 | ||
|
|
01df853592 | ||
|
|
020ddfea07 | ||
|
|
b4a5633bd4 | ||
|
|
f579516d64 | ||
|
|
6f887b00f1 | ||
|
|
0ce6c05e05 | ||
|
|
fe9f1c13e2 | ||
|
|
e3874b6237 | ||
|
|
e0a64e828e | ||
|
|
a8fb23287c | ||
|
|
ed9bb3216c | ||
|
|
1b013ed91b | ||
|
|
8e01dcaabd | ||
|
|
0836afc0c0 | ||
|
|
8296278e1c | ||
|
|
6028e275da | ||
|
|
314b88b146 | ||
|
|
de4295caf1 | ||
|
|
c5e66b3ec5 | ||
|
|
aba5efcf40 | ||
|
|
e69fd18256 | ||
|
|
0be105e9dd | ||
|
|
a1fef27a59 | ||
|
|
77ec19a4d6 | ||
|
|
d4d6f0674d | ||
|
|
e334246cef | ||
|
|
81a8c35873 | ||
|
|
9c3dbfebcb | ||
|
|
cd9e82938b | ||
|
|
0fb6ea49b3 | ||
|
|
a3dae83bf7 | ||
|
|
1bd486a6c0 | ||
|
|
b965f7b561 | ||
|
|
45aef7339e | ||
|
|
fb7ecec17e | ||
|
|
7b29d741b0 | ||
|
|
67f4f014c4 | ||
|
|
c42d314cc9 | ||
|
|
102192c631 | ||
|
|
cfe9055cb7 | ||
|
|
501bc234ee | ||
|
|
c07e5971f5 | ||
|
|
bbdecc324a | ||
|
|
8b59d021d3 | ||
|
|
3221dbe877 | ||
|
|
57d4b5a415 | ||
|
|
6580de08d4 | ||
|
|
161929e280 | ||
|
|
e9fd5f55a8 | ||
|
|
ae1c92a0ce | ||
|
|
127eb446ed | ||
|
|
66824240d8 | ||
|
|
115ecad9a6 | ||
|
|
d857668109 | ||
|
|
b72c58452e | ||
|
|
2b1122dce9 | ||
|
|
9f0bb8bd24 | ||
|
|
edeac5e6ff | ||
|
|
e0312c3fbb | ||
|
|
e94a52d7fd | ||
|
|
ace39d5a70 | ||
|
|
9bc5d2d06d | ||
|
|
57575f7199 | ||
|
|
2ad71a51c7 | ||
|
|
9dd17929df | ||
|
|
543c2b452e | ||
|
|
a65e80c82a | ||
|
|
185a826387 | ||
|
|
40d6ca9bf0 | ||
|
|
c129f38e6b | ||
|
|
0da5d72d91 | ||
|
|
1c4b9b8c3d | ||
|
|
c94abeca07 | ||
|
|
f14aea2070 | ||
|
|
1d664cbe4c | ||
|
|
2b2b6cbb60 | ||
|
|
c7b16d6ddd | ||
|
|
d068f07c4b | ||
|
|
d9d8d44013 | ||
|
|
335fa4e21d | ||
|
|
29bb16ba1a | ||
|
|
4d57e679bf | ||
|
|
7a36d988ba | ||
|
|
56eba166f7 | ||
|
|
7f84a5eadf | ||
|
|
d8c3d8bb02 | ||
|
|
62ab8539c6 | ||
|
|
42533bf63c | ||
|
|
e6009c2d92 | ||
|
|
fb76ecd512 | ||
|
|
9be4a4e180 | ||
|
|
d7b42acced | ||
|
|
1003101976 | ||
|
|
c29d076f26 | ||
|
|
757684f383 | ||
|
|
4321e15323 | ||
|
|
7389a98f8b | ||
|
|
447b00c3cd | ||
|
|
305becec38 | ||
|
|
12ba219773 | ||
|
|
063afff53b | ||
|
|
0093c97873 | ||
|
|
a29753ef86 | ||
|
|
02b72cbce7 | ||
|
|
e6e6327165 | ||
|
|
a343c5d1e2 | ||
|
|
44d1920e2b | ||
|
|
5ad75aa465 | ||
|
|
b5e44100a2 | ||
|
|
f6a57b788a | ||
|
|
d00b2cf160 | ||
|
|
c7c6595942 | ||
|
|
d715c4fa54 | ||
|
|
1480fcc8c1 | ||
|
|
c22f04bdba | ||
|
|
96fdd62821 | ||
|
|
a7d42ba561 | ||
|
|
5645bfdee3 | ||
|
|
82b4e14194 | ||
|
|
85a42c9611 | ||
|
|
40a0409f7e | ||
|
|
4d02b6fcac | ||
|
|
b551f76c9c | ||
|
|
4134cd4bb1 | ||
|
|
32f0fe5f3d | ||
|
|
d8c8b7f727 | ||
|
|
620a01280a | ||
|
|
16bf8e880e | ||
|
|
7a95aabb8d | ||
|
|
aec83de635 | ||
|
|
30ada80b0b | ||
|
|
6def1d8cfc | ||
|
|
24f7066f6d | ||
|
|
bbf0da86f2 | ||
|
|
d70cf3d216 | ||
|
|
eb27e9e620 | ||
|
|
29facb35f3 | ||
|
|
65975124f4 | ||
|
|
d1dc3e470f | ||
|
|
96428938b9 | ||
|
|
ddfe68699b | ||
|
|
852af31e10 | ||
|
|
b70c47a402 | ||
|
|
65b3bbdfd8 | ||
|
|
5dede62b0e | ||
|
|
b8a8c412ca | ||
|
|
0cfac796d8 | ||
|
|
5debdbed94 | ||
|
|
b3d6830814 | ||
|
|
cfdaf0e11f | ||
|
|
2f969ff05d | ||
|
|
8be04781a5 | ||
|
|
e0259fe20c | ||
|
|
8453660872 | ||
|
|
b0d343109b | ||
|
|
ab8d327d18 | ||
|
|
1552bcca70 | ||
|
|
978e05e77f | ||
|
|
f40ab91c54 | ||
|
|
627e948c59 | ||
|
|
1daf5d7d03 | ||
|
|
9e089b6e4d | ||
|
|
52a5b9bab4 | ||
|
|
08988962c1 | ||
|
|
5f5591ca42 | ||
|
|
509dbd870a | ||
|
|
f0732a2a12 | ||
|
|
fb7b0be305 | ||
|
|
f9b95c078a | ||
|
|
afa2a8a7e5 | ||
|
|
5a1ff61bc0 | ||
|
|
c141fd151a | ||
|
|
97c7098e14 | ||
|
|
a73c6dfd6b | ||
|
|
1b1c9d1699 | ||
|
|
6d6bfaaa7f | ||
|
|
d57bd74132 | ||
|
|
365d059b0c | ||
|
|
d5c6b6157c | ||
|
|
7253e8af2e | ||
|
|
0067a50cb0 | ||
|
|
8f53faec56 | ||
|
|
a14062909b | ||
|
|
acec70a69c | ||
|
|
470d1f930d | ||
|
|
63f788c16e | ||
|
|
6eac64beab | ||
|
|
37d5841d1c | ||
|
|
1ec37a43f6 | ||
|
|
022249950e | ||
|
|
8cb42dee36 | ||
|
|
a588df0f0d | ||
|
|
99cf12c2b5 | ||
|
|
6d607e697d | ||
|
|
bc0aa86381 | ||
|
|
e2a4d2f3e5 | ||
|
|
9289fb3467 | ||
|
|
871e04a8ba | ||
|
|
63871ccd17 | ||
|
|
6c43bcbf8d | ||
|
|
959812300b | ||
|
|
23858b1b6a | ||
|
|
0e391ef26d | ||
|
|
4364c0eb07 | ||
|
|
22c4c7110d | ||
|
|
c7380e30fd | ||
|
|
c2968a7a33 | ||
|
|
3423718ac1 | ||
|
|
ad41a99d10 | ||
|
|
65160e8c14 | ||
|
|
d2e05f84b2 | ||
|
|
f7ce5cefd5 | ||
|
|
6b017f917e | ||
|
|
ba99d41972 | ||
|
|
126d8fbab9 | ||
|
|
0e14445685 | ||
|
|
4a3b91eeab | ||
|
|
76200dcd1f | ||
|
|
448c2e0239 | ||
|
|
a595d793d6 | ||
|
|
82f1b71c26 | ||
|
|
e6fec6d9bf | ||
|
|
7893814d03 | ||
|
|
c11ffc750d | ||
|
|
2f9349fb40 | ||
|
|
7bbb039da1 | ||
|
|
f80d2445b0 | ||
|
|
a4a3016c05 | ||
|
|
9f2b1e423f | ||
|
|
627ca21a56 | ||
|
|
1e788be509 | ||
|
|
00f31efc3d | ||
|
|
0e80bda371 | ||
|
|
03ef441f1b | ||
|
|
59021281e9 | ||
|
|
be1b345fbb | ||
|
|
9ba7f8fd7a | ||
|
|
c1c78585ba | ||
|
|
f5f183e7ce | ||
|
|
df175f7198 | ||
|
|
48ac39336c | ||
|
|
724df8c59e | ||
|
|
60a19494de | ||
|
|
6c4e2b4fdb | ||
|
|
74bbd19d85 | ||
|
|
81ada96450 | ||
|
|
0e44f1ac08 | ||
|
|
4ff6e8c6cd | ||
|
|
56dede2679 | ||
|
|
a9f2d62d30 | ||
|
|
988554dc5f | ||
|
|
86b4c9a18c | ||
|
|
fd4f96899a | ||
|
|
930f411549 | ||
|
|
3be9e17ecc | ||
|
|
d1d5ad91bd | ||
|
|
2d07a3ca4c | ||
|
|
ef8d1f021b | ||
|
|
d222f022aa | ||
|
|
84905544a3 | ||
|
|
751bf80338 | ||
|
|
4c3569c001 | ||
|
|
b582617535 | ||
|
|
7b017eb7f7 | ||
|
|
3b56427552 | ||
|
|
d95ba09e00 | ||
|
|
a26772055a | ||
|
|
042f653798 | ||
|
|
62075df80d | ||
|
|
f2a0670861 | ||
|
|
b0ca95807c | ||
|
|
2baecc90ff | ||
|
|
7dde0d0599 | ||
|
|
3d5df5938d | ||
|
|
746327553e | ||
|
|
fb748e78e8 | ||
|
|
23e8c84fa3 | ||
|
|
c52aa7d998 | ||
|
|
6473b076d5 | ||
|
|
e2a3d78bc0 | ||
|
|
e514b1b9e1 | ||
|
|
991645c957 | ||
|
|
ff094c2a2b | ||
|
|
efdef8ff1c | ||
|
|
aa048d7bdc | ||
|
|
ac22aa3f84 | ||
|
|
3b99ceecb2 | ||
|
|
3af8702d48 | ||
|
|
ac634a4ab2 | ||
|
|
da494043c8 | ||
|
|
220535e72a | ||
|
|
d816345560 | ||
|
|
e05f258fb3 | ||
|
|
a92294dba8 | ||
|
|
bb8540828e | ||
|
|
ea59a196bb | ||
|
|
bfdfa33b62 | ||
|
|
ff0bf30eac | ||
|
|
400054ae58 | ||
|
|
233dd993e2 | ||
|
|
d09c63ca7d | ||
|
|
a4cc514b9a | ||
|
|
d49ec34f0a | ||
|
|
598c0404a2 | ||
|
|
fc391a9d8b | ||
|
|
cf4e754aed | ||
|
|
1da2729251 | ||
|
|
ed233c1cb6 | ||
|
|
ed7804e0f3 | ||
|
|
a1bd8eea96 | ||
|
|
288309055a | ||
|
|
7515510880 | ||
|
|
1a38ed7b73 | ||
|
|
b4171eddc1 | ||
|
|
bc3d216915 | ||
|
|
4cbcdef9c2 | ||
|
|
978df8fb62 | ||
|
|
d812a1fc6c | ||
|
|
080c9c8fce | ||
|
|
fb5b166a37 | ||
|
|
457ad42565 | ||
|
|
8ca1acb172 | ||
|
|
b1bd1513b5 | ||
|
|
63d796dcd4 | ||
|
|
8e520a6368 | ||
|
|
e037096914 | ||
|
|
543337ac8b | ||
|
|
d60d765008 | ||
|
|
f32c2549d7 | ||
|
|
ff808476a2 | ||
|
|
cc389acbb9 | ||
|
|
5fe68d6027 | ||
|
|
3151419b92 | ||
|
|
22c5858ab6 | ||
|
|
519d2e2836 | ||
|
|
189eb4c309 | ||
|
|
481c0e1bcd | ||
|
|
6efa1b268f | ||
|
|
e7793c8ef2 | ||
|
|
98b61a7845 | ||
|
|
5c66bbd809 | ||
|
|
7390d5c1ac | ||
|
|
4c5dfa093c | ||
|
|
ffa4189763 | ||
|
|
d0ea99bc4c | ||
|
|
baf7e2ea44 | ||
|
|
10e78757b9 | ||
|
|
a038bf775a | ||
|
|
f8d01a810c | ||
|
|
9c45075cd1 | ||
|
|
12b5e18f0a | ||
|
|
dc6a4b3c3a | ||
|
|
2303fd3c7b | ||
|
|
3836bd3cc1 | ||
|
|
8c40c08b57 | ||
|
|
0c83df7933 | ||
|
|
c3ea5f02e2 | ||
|
|
9a6c54ce7c | ||
|
|
ae619b8faf | ||
|
|
88161468e8 | ||
|
|
a580563be5 | ||
|
|
95cfe4557c | ||
|
|
5ac94f7110 | ||
|
|
090fffe356 | ||
|
|
2a942ff55c | ||
|
|
9b552fb857 | ||
|
|
ab15ce7e17 | ||
|
|
4bd9e25fc4 | ||
|
|
3bd723202c |
@@ -11,3 +11,9 @@ config.h
|
||||
robots.json
|
||||
roverd-dummy
|
||||
server/config.yaml
|
||||
server/package-lock.json
|
||||
package-lock.json
|
||||
server/data/discord-guilds.json
|
||||
server/data/community-goal.json
|
||||
server/data/admin-reason.json
|
||||
server/data
|
||||
|
||||
@@ -1,87 +1,7 @@
|
||||
# Multi Roomba Rover
|
||||
A system for controlling create 2 compatible roombas through a webpage.
|
||||
|
||||
a remake of my RoombaRover project with a decentralized and embedded approach
|
||||
supports multiple roombas
|
||||
Docs coming "soon"
|
||||
|
||||
on each roomba:
|
||||
- a raspberry pi zero 2 w
|
||||
- a raspberry pi camera
|
||||
- roomba's serial port hooked up to the built in serial port on the raspberry pi
|
||||
|
||||
pi provisioning:
|
||||
- enable serial port
|
||||
- disable wifi powersave
|
||||
- disable bluetooth
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets.
|
||||
- `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`.
|
||||
- `pi/bin` / `pi/systemd`: helper scripts + systemd units for the rover (roverd itself plus the SRT video publisher that streams camera feeds to the server).
|
||||
- `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# build the Pi agent (armv7)
|
||||
cd pi/roverd
|
||||
mkdir -p ../../dist
|
||||
make pi-build
|
||||
|
||||
# start the server + UI
|
||||
cd ../../server
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
Need a fake rover or multi-rover testing without hardware? Use the dummy build:
|
||||
|
||||
```bash
|
||||
cd pi/roverd
|
||||
mkdir -p ../../dist
|
||||
make dummy
|
||||
./../../dist/roverd-dummy -config ./roverd.sample.yaml
|
||||
```
|
||||
|
||||
The dummy binary connects to the Node server, emits simulated sensor frames, and logs every command it receives, so you can spin up as many virtual rovers as you’d like on your dev machine.
|
||||
|
||||
## Admin config & authentication
|
||||
|
||||
Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UI’s login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks can’t grant extra control.
|
||||
|
||||
Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it installs roverd plus the SRT video publisher service):
|
||||
|
||||
```bash
|
||||
cd ~/MultiRoombaRover
|
||||
sudo ./pi/install_roverd.sh
|
||||
```
|
||||
|
||||
Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set `name` to the rover’s ID, and (optionally) override `media.publishUrl` if your control server isn’t `192.168.0.86`. The video publisher service (`video-publisher.service`) captures the Pi camera with `rpicam-vid`/`libcamera-vid`, pipes the raw H264 into the stock FFmpeg binary, and publishes via SRT to the server’s mediaMTX instance at `srt://<server>:9000?streamid=#!::r=<name>,m=publish`. The installer now just pulls `libcamera-apps` + `ffmpeg` from apt (no custom build). Use the “Restart Camera” button if you enable media management so roverd can bounce the publisher service remotely.
|
||||
Heads-up: the BRC pulser now uses libgpiod; make sure the `roverd` service account is in the `gpio` group (or otherwise allowed to access `/dev/gpiochip*`) and set `brc.gpioChip` if your hardware exposes a different chip name.
|
||||
|
||||
## Fedora server deployment
|
||||
|
||||
Run the installer from inside the `server/` directory after cloning the repo onto your Fedora 43 Server box:
|
||||
|
||||
```bash
|
||||
cd ~/MultiRoombaRover/server
|
||||
sudo ./install_server.sh
|
||||
```
|
||||
|
||||
The script must be executed via `sudo` from the user that owns the repo. It will:
|
||||
|
||||
- install Node.js/npm plus curl/tar
|
||||
- run `npm install --production`
|
||||
- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins + `media.whepBaseUrl`)
|
||||
- download mediaMTX v1.15.3 and drop it into `/usr/local/bin`
|
||||
- write `/etc/mediamtx/mediamtx.yml` from `server/mediamtx/mediamtx.yml` (SRT ingest on :9000, open ingest, viewer auth webhook at `/mediamtx/auth`)
|
||||
- create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly
|
||||
|
||||
Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically.
|
||||
|
||||
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (`http://192.168.0.86:8889`). The client automatically requests `http://<base>/<rover-id>/whep`, so you never have to embed IDs or path templates. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer.
|
||||
|
||||
### Video handshake + diagnostics
|
||||
|
||||
- Every `video:request` returns `{ url, token }`. The browser posts the SDP offer to `url` and includes `Authorization: Basic base64(token:token)`. mediaMTX forwards the username (`token`) to `/mediamtx/auth`, which checks the socket’s permissions (driver assignment, admin/spectator role, lockdown state) and either returns 200 or 401—no query parameters are involved anymore.
|
||||
- To see what mediaMTX is ingesting from the Pis, run `npm run check:media` (or `node scripts/checkMedia.js`). It hits `/v3/paths/list` and prints each rover’s `ready` state and byte counters so you can instantly spot publish issues.
|
||||
## Basic installation
|
||||
-
|
||||
@@ -1,33 +0,0 @@
|
||||
# access control modes:
|
||||
- open
|
||||
- open to the public
|
||||
- users are randomly assigned a rover to drive, with priority on the rover with the least amount of drivers
|
||||
- if there are multiple people on one rover, those people will all be controlling that rover at the same time
|
||||
- turns
|
||||
- open to the public
|
||||
- users are randomly assigned to a rover same as open mode
|
||||
- if there are multiple people on one rover, they will each have one minute at a time to drive.
|
||||
- the turn queue loops
|
||||
- the rover will stop moving and stop all aux. motors if the turn switches to a different person
|
||||
- admin
|
||||
- admin authentication is required to access the driver page at all
|
||||
- lockdown
|
||||
- ONLY lockdown admins can access the driver page
|
||||
- the future spectator page is DISABLED (not even a way to log into it)
|
||||
|
||||
# roles in the access control system:
|
||||
- user
|
||||
- default, for normal people who visit the site
|
||||
- admin
|
||||
- authentication needed
|
||||
- can drive and view any rover when not on lockdown, no matter who is controlling it
|
||||
- can switch modes (including switching to lockdown mode)
|
||||
- lockdown admin
|
||||
- authentication needed
|
||||
- only works if said admin has lockdown: enabled in config
|
||||
- can ALWAYS access and control EVERYTHING that there is to do on the site
|
||||
|
||||
# other things to keep in mind:
|
||||
- in the future, there will be a Discord bot for community alerts and a few admin controls
|
||||
- wherever the admin list is configured, there has to be a spot for their discord ID
|
||||
- admin's discord IDs are linked to their admin name server-side
|
||||
Vendored
+3
-1
@@ -1,6 +1,6 @@
|
||||
#configuration for roverd
|
||||
name: dummy1
|
||||
serverUrl: ws://192.168.0.84:8080/rover
|
||||
serverUrl: ws://127.0.0.1:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyS0
|
||||
baud: 115200
|
||||
@@ -18,3 +18,5 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
enabled: false
|
||||
|
||||
Vendored
+3
-1
@@ -1,6 +1,6 @@
|
||||
#configuration for roverd
|
||||
name: dummy2
|
||||
serverUrl: ws://192.168.0.84:8080/rover
|
||||
serverUrl: ws://127.0.0.1:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyS0
|
||||
baud: 115200
|
||||
@@ -18,3 +18,5 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
enabled: false
|
||||
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
#configuration for roverd
|
||||
name: dummy3
|
||||
serverUrl: ws://127.0.0.1:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyS0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 25
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
battery:
|
||||
full: 2068
|
||||
warn: 1700
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
enabled: false
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,112 +0,0 @@
|
||||
# Pi Deployment Guide
|
||||
|
||||
## Tooling
|
||||
|
||||
Fedora 43:
|
||||
|
||||
```bash
|
||||
sudo dnf install golang libgpiod
|
||||
```
|
||||
|
||||
Cross-compiling roverd for Pi Zero 2 W (ARMv7):
|
||||
|
||||
```bash
|
||||
cd pi/roverd
|
||||
mkdir -p ../../dist
|
||||
make pi-build
|
||||
```
|
||||
|
||||
The binary is placed in `dist/roverd` (relative to the repo root).
|
||||
|
||||
### Dummy rover build (for local testing)
|
||||
|
||||
If you need extra “virtual” rovers on your laptop/CI box, build the dummy binary:
|
||||
|
||||
```bash
|
||||
cd pi/roverd
|
||||
mkdir -p ../../dist
|
||||
make dummy
|
||||
```
|
||||
|
||||
Run the resulting `dist/roverd-dummy` on any machine; it will connect to the server, stream fake Group 100 sensor data, and log drive commands so you can test multi-rover features without additional hardware.
|
||||
|
||||
## Automated installation (recommended)
|
||||
|
||||
Install the Raspberry Pi camera helpers (Bookworm ships them):
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install libcamera-apps
|
||||
```
|
||||
|
||||
Once the binary (and repo) are on the Pi, run the helper script from the repo root:
|
||||
|
||||
```bash
|
||||
cd ~/MultiRoombaRover
|
||||
sudo ./pi/install_roverd.sh
|
||||
```
|
||||
|
||||
What the script does:
|
||||
|
||||
- creates the `roverd` service account (dialout/gpio/video groups) if missing and installs `/usr/local/bin/roverd`
|
||||
- copies `pi/roverd/roverd.sample.yaml` to `/etc/roverd.yaml` if the file is absent (existing configs are left untouched)
|
||||
- installs/enables the `roverd.service` systemd unit, restarting it automatically when a config already exists
|
||||
- installs `/usr/local/bin/video-publisher`, drops `video-publisher.service`, and enables it so video is published automatically on boot
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) |
|
||||
| `-c PATH` | seed `/etc/roverd.yaml` from another template |
|
||||
|
||||
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish…` (the host comes from `serverUrl`).
|
||||
|
||||
Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites the roverd + video-publisher binaries and drops the latest systemd units so the only configuration you ever touch manually is `/etc/roverd.yaml`. `roverd` rewrites `/var/lib/roverd/video.env` on startup, so no other files need editing.
|
||||
## Manual installation
|
||||
|
||||
1. Copy the binary and config:
|
||||
```bash
|
||||
sudo useradd -r -s /usr/sbin/nologin roverd || true
|
||||
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
|
||||
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
|
||||
```
|
||||
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`.
|
||||
|
||||
2. Install the systemd unit:
|
||||
```bash
|
||||
sudo install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now roverd.service
|
||||
```
|
||||
|
||||
`roverd` requires access to `/dev/ttyAMA0` and `/dev/gpiochip*`; keeping it under its own user ensures the rest of the system stays isolated—just make sure the account belongs to the `dialout` and `gpio` groups so it can reach the UART and libgpiod.
|
||||
The video publisher needs read access to the camera devices (`/dev/media*`, `/dev/video*`), so the install script adds the `roverd` service account to the `video` group; if you created the user manually, make sure it belongs to `video`.
|
||||
**BRC note:** configure `brc.gpioPin` (and `brc.gpioChip` if you’re not using `gpiochip0`) and ensure the `roverd` user has permission to toggle that line—no root privileges are required anymore.
|
||||
If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl <action> <media.service>` (the installer wires `video-publisher.service` to run as `roverd`, so no sudo tweaks are required unless you rename it).
|
||||
|
||||
## Video publisher details
|
||||
|
||||
The `video-publisher.service` unit runs `/usr/local/bin/video-publisher`, piping `rpicam-vid`/`libcamera-vid` straight into the stock FFmpeg package:
|
||||
|
||||
```bash
|
||||
rpicam-vid (or libcamera-vid) --inline --timeout 0 --width=WIDTH --height=HEIGHT \
|
||||
--framerate=FPS --bitrate=BITRATE --codec h264 --profile baseline --output - \
|
||||
| ffmpeg -hide_banner -loglevel warning -fflags nobuffer \
|
||||
-f h264 -i pipe:0 -c:v copy -an -flush_packets 1 -f mpegts $PUBLISH_URL
|
||||
```
|
||||
|
||||
`/var/lib/roverd/video.env` carries all tunables (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites the file whenever you restart it or hit the “Restart Camera” button so the publisher always inherits the correct rover ID + bitrate knobs. Because we now use the distro FFmpeg build with SRT enabled, the installer is much faster—no custom compile steps.
|
||||
Use `sudo systemctl status video-publisher` to watch logs; the unit auto-restarts whenever the connection drops or FFmpeg exits with an error.
|
||||
|
||||
## Server + UI
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
This launches the HTTP server (serving the barebones UI) and the rover WebSocket endpoint at `ws://<server>:8080/rover`. The UI expects `roverd` instances to send `hello` frames so it can populate the rover list. Use the mode buttons to emit Start/Safe/Full/Passive/Dock commands (they send the raw OI opcode bytes), tap the sensor toggle to request Group 100 streaming, and use WASD for drive testing; the UI emits Drive Direct commands ~8 times per second, so the rover sees them immediately.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Video flow (SRT ingest, WHEP playback)
|
||||
|
||||
1. **Raspberry Pi Zero 2 W**
|
||||
- `pi/bin/video-publisher.sh` captures the CSI camera with `rpicam-vid`/`libcamera-vid` using the onboard H.264 encoder (`--inline --profile baseline --bitrate …`).
|
||||
- The Annex-B stream is piped into the stock FFmpeg package and pushed to the control server over SRT as MPEG‑TS: `ffmpeg -f h264 -i - -c copy -f mpegts "srt://<server>:9000?streamid=#!::r=rover-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316"`.
|
||||
- Configuration lives in `/var/lib/roverd/video.env` (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites this file whenever the rover config changes, so onboarding a new rover is just flashing the SD card, setting its `name`, and plugging it into the trusted LAN—**no auth or per-rover server config is required on the Pi ⇄ server hop.**
|
||||
|
||||
2. **mediaMTX on the server**
|
||||
- Single wildcard path handles every rover:
|
||||
```yaml
|
||||
paths:
|
||||
"~^rover-(?P<id>[a-z0-9_-]+)$":
|
||||
source: publisher
|
||||
sourceOnDemand: no
|
||||
sourceProtocol: srt
|
||||
readBufferCount: 512
|
||||
webrtcEnable: yes
|
||||
webrtcMaxPlayoutDelay: 0
|
||||
alwaysRemuxWhep: no
|
||||
```
|
||||
- Pis publish to `srt://<server>:9000` with the streamid above; mediaMTX auto-creates the path and fans it out over WHEP/WebRTC.
|
||||
- Only playback is gated: mediaMTX calls the Node server to validate JWTs on `/whep/rover-<id>`, so “locking” a stream is as simple as refusing to mint viewer tokens for that rover. Ingest stays unauthenticated because it lives on a secure LAN.
|
||||
|
||||
3. **Web clients**
|
||||
- Tiny helper (React hook or vanilla class) that:
|
||||
1. Requests a viewer token for rover `<id>`.
|
||||
2. Issues a `POST` to `<mediamtx-host>/<rover-id>/whep` with `Authorization: Basic base64(token:token)` (token issued by the server when the client calls `video:request`).
|
||||
3. Maintains auto-reconnect timers on ICE failure so dashboard widgets can come/go without reloading the page.
|
||||
- Operator dashboard mounts one player tied to the assigned rover. The spectator view instantiates one player per tile, muting + pausing hidden elements to keep CPU usage sane even when every rover is shown simultaneously.
|
||||
|
||||
# Deployment checklist
|
||||
|
||||
- `pi/install_roverd.sh` installs the `video-publisher` helper, drops `/var/lib/roverd/video.env`, and pulls in `libcamera-apps` + `ffmpeg` from apt. No custom FFmpeg, no WHIP builds, no extra config—Pis become plug-and-play.
|
||||
- `roverd` derives `media.publishUrl` automatically from `serverUrl`: `srt://<server>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316`. The media supervisor rewrites `video.env` and manages `video-publisher.service` whenever you hit “Restart Camera” or change `/etc/roverd.yaml`.
|
||||
- The server’s mediaMTX config switches to the wildcard block above, leaves SRT ingest open, and enforces JWTs only on WHEP viewers. Fan-out stays inside mediaMTX so every browser sees the same low-latency stream (≈250–350 ms glass-to-glass).
|
||||
- Adding a rover = flash SD → set `/etc/roverd.yaml` (`name`, `serverUrl`, camera knobs if needed) → boot it. The server auto-discovers the new `rover-<id>` stream with zero manual edits.
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
## multi roomba rover
|
||||
|
||||
a website where people can control multiple irobot create 2 robots in real time 100% responsively
|
||||
with a raspberry pi zero 2 W on each roomba, along with a raspberry pi camera
|
||||
a central nodejs control server will tell the raspberry pis what to do with the roomba
|
||||
|
||||
## the pi side (roomba side)
|
||||
- pi's onboard UART is hooked up to the roomba's serial port
|
||||
- another GPIO pin connected to the roomba's BRC pin
|
||||
- pull it low for one second every minute to keep the roomba awake
|
||||
- streams the rpi camera over webRTC with mediamtx
|
||||
- streams roomba sensor group 100 to the server
|
||||
- sensor streaming is required and important, but is allowed to falter sometimes
|
||||
- listens for roomba commands from the server
|
||||
- commands NEED to happen
|
||||
- roomba control program needs to be simple, lightweight, and 100% responsive
|
||||
|
||||
## pi -> server communication
|
||||
- stateless
|
||||
- streaming based
|
||||
- on connection, the pi will send the following info:
|
||||
- rover's name
|
||||
- motor enable / disable
|
||||
- vacuum
|
||||
- main brush
|
||||
- side brush
|
||||
- battery full number
|
||||
- battery warning number
|
||||
- battery urgent number
|
||||
|
||||
## nodejs server
|
||||
- KISS
|
||||
- decode the sensor data from each roomba
|
||||
- can support multiple roombas connected from the ground up
|
||||
- keep it simple, worry about getting the pi comms right.
|
||||
- but the server DOES have to exist for testing
|
||||
- IS the web server, hosts an entire static folder for the web UI
|
||||
|
||||
## server -> web UI communication
|
||||
- socket.io
|
||||
- don't do anything fancy with the socket.io setup
|
||||
- it works fine out of the box, we will optimize it later
|
||||
|
||||
## the web UI
|
||||
- KISS
|
||||
- plain old html. no styling even. just bare minimum for testing
|
||||
- what it needs to do:
|
||||
- allow user to select the roomba from a list
|
||||
- make the selected roomba drive with WASD
|
||||
- have buttons to set the OI mode, and tell the roomba to dock
|
||||
- show a plain list of the sensor data from the selected roomba
|
||||
|
||||
### general javascript programming guidelines (applies to the web UI too)
|
||||
- everything ES6
|
||||
- one entrypoint file in the web UI
|
||||
- everything modular
|
||||
- everything easy to read, understand, and work on
|
||||
- comment where you think is best to describe whats going on
|
||||
@@ -0,0 +1,88 @@
|
||||
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
||||
options snd_rpi_googlevoicehat_soundcard index=0
|
||||
|
||||
# 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 "dmixer"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Horn volume control.
|
||||
pcm.horn_softvol {
|
||||
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 "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"
|
||||
}
|
||||
|
||||
pcm.horn {
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
}
|
||||
|
||||
pcm.forward {
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
}
|
||||
|
||||
# Capture alias used by rover config defaults.
|
||||
pcm.rovermic {
|
||||
type plug
|
||||
slave.pcm "hw:0,0"
|
||||
}
|
||||
|
||||
# Defaults: TTS direct playback + raw capture on the HAT.
|
||||
pcm.!default {
|
||||
type asym
|
||||
playback.pcm "tts"
|
||||
capture.pcm "rovermic"
|
||||
}
|
||||
|
||||
ctl.!default {
|
||||
type hw
|
||||
card 0
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
|
||||
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
|
||||
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||
else
|
||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v aplay >/dev/null 2>&1; then
|
||||
APLAY_BIN_PATH="$(command -v aplay)"
|
||||
else
|
||||
echo "aplay not found; install it via apt install alsa-utils." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LAST_FFMPEG_STATUS="unknown"
|
||||
LAST_APLAY_STATUS="unknown"
|
||||
|
||||
run_pipeline() {
|
||||
set +e
|
||||
"${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-flags low_delay \
|
||||
-analyzeduration 200k \
|
||||
-probesize 32k \
|
||||
-i "${AUDIO_FORWARD_URL}" \
|
||||
-vn \
|
||||
-ac 1 \
|
||||
-ar 16000 \
|
||||
-f s16le \
|
||||
pipe:1 \
|
||||
| "${APLAY_BIN_PATH}" \
|
||||
-q \
|
||||
-D "${PLAYBACK_DEVICE}" \
|
||||
-t raw \
|
||||
-f S16_LE \
|
||||
-r 16000 \
|
||||
-c 1
|
||||
local rc=$?
|
||||
local -a statuses=("${PIPESTATUS[@]}")
|
||||
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
|
||||
LAST_APLAY_STATUS="${statuses[1]:-unknown}"
|
||||
set -e
|
||||
return "${rc}"
|
||||
}
|
||||
|
||||
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||
|
||||
while true; do
|
||||
if run_pipeline; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Audio forward listener exited ffmpeg=${LAST_FFMPEG_STATUS:-unknown} aplay=${LAST_APLAY_STATUS:-unknown}, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
done
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
AUDIO_ENABLE="${AUDIO_ENABLE:-0}"
|
||||
if [[ "${AUDIO_ENABLE}" -ne 1 ]]; then
|
||||
echo "Audio capture disabled; skipping audio-only publisher" >&2
|
||||
exit 0
|
||||
fi
|
||||
: "${AUDIO_PUBLISH_URL:?AUDIO_PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
|
||||
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:0,0}"
|
||||
AUDIO_RATE="${AUDIO_RATE:-48000}"
|
||||
AUDIO_CHANNELS="${AUDIO_CHANNELS:-2}"
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||
else
|
||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_pipeline() {
|
||||
arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw \
|
||||
| "${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-rtbufsize 0 \
|
||||
-thread_queue_size 4096 \
|
||||
-f s32le \
|
||||
-ar "${AUDIO_RATE}" \
|
||||
-ac "${AUDIO_CHANNELS}" \
|
||||
-i pipe:0 \
|
||||
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=25dB" \
|
||||
-c:a libopus \
|
||||
-b:a 24000 \
|
||||
-ar:a 16000 \
|
||||
-ac:a 1 \
|
||||
-application lowdelay \
|
||||
-frame_duration 20 \
|
||||
-compression_level 0 \
|
||||
-f mpegts \
|
||||
"${AUDIO_PUBLISH_URL}"
|
||||
}
|
||||
|
||||
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||
|
||||
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
|
||||
sleep 2
|
||||
done
|
||||
+115
-45
@@ -1,74 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Keep history expansion off so values containing "!" are safe.
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
|
||||
exit 1
|
||||
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
# Load KEY=VALUE pairs from ENV_FILE WITHOUT evaluating shell metacharacters.
|
||||
# This makes URLs containing characters like '&' and '#!' safe without requiring quoting.
|
||||
load_env_file() {
|
||||
local content=""
|
||||
|
||||
if [[ -r "$ENV_FILE" ]]; then
|
||||
content="$(cat "$ENV_FILE")"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
# Try to read via sudo without prompting (useful when the service runs as an unprivileged user)
|
||||
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$content" ]]; then
|
||||
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local line key val
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Skip blank lines and full-line comments.
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
# Support optional leading 'export '
|
||||
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
else
|
||||
# Ignore anything that isn't a simple assignment.
|
||||
continue
|
||||
fi
|
||||
|
||||
# Trim leading/trailing whitespace in value.
|
||||
val="${val#${val%%[![:space:]]*}}"
|
||||
val="${val%${val##*[![:space:]]}}"
|
||||
|
||||
# If value is wrapped in matching single or double quotes, unwrap.
|
||||
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
fi
|
||||
|
||||
# Assign without evaluation.
|
||||
printf -v "$key" '%s' "$val"
|
||||
export "$key"
|
||||
done <<< "$content"
|
||||
}
|
||||
|
||||
load_env_file
|
||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
|
||||
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
|
||||
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
||||
VIDEO_FPS="${VIDEO_FPS:-30}"
|
||||
# Defaults tuned for OV5647: use 4:3 output and force the common 2x2 binned full-FOV mode.
|
||||
VIDEO_WIDTH="640"
|
||||
VIDEO_HEIGHT="480"
|
||||
VIDEO_FPS="30"
|
||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
||||
|
||||
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
||||
FLIP_ARGS=(--rotation 180)
|
||||
|
||||
MODE_ARGS=()
|
||||
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
||||
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}")
|
||||
fi
|
||||
|
||||
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
||||
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
||||
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
||||
elif command -v rpicam-vid >/dev/null 2>&1; then
|
||||
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
|
||||
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
|
||||
elif command -v libcamera-vid >/dev/null 2>&1; then
|
||||
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
|
||||
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
|
||||
else
|
||||
echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2
|
||||
exit 1
|
||||
echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||
else
|
||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||
exit 1
|
||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_pipeline() {
|
||||
"${LIBCAMERA_BIN_PATH}" \
|
||||
--inline \
|
||||
--timeout 0 \
|
||||
--width "${VIDEO_WIDTH}" \
|
||||
--height "${VIDEO_HEIGHT}" \
|
||||
--framerate "${VIDEO_FPS}" \
|
||||
--bitrate "${VIDEO_BITRATE}" \
|
||||
--codec h264 \
|
||||
--profile baseline \
|
||||
--denoise cdn_off \
|
||||
--nopreview \
|
||||
--output - \
|
||||
| "${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-use_wallclock_as_timestamps 1 \
|
||||
-f h264 \
|
||||
-i pipe:0 \
|
||||
-c:v copy \
|
||||
-an \
|
||||
-flush_packets 1 \
|
||||
-f mpegts \
|
||||
"${PUBLISH_URL}"
|
||||
"${LIBCAMERA_BIN_PATH}" \
|
||||
--inline \
|
||||
--timeout 0 \
|
||||
"${MODE_ARGS[@]}" \
|
||||
--width "${VIDEO_WIDTH}" \
|
||||
--height "${VIDEO_HEIGHT}" \
|
||||
"${FLIP_ARGS[@]}" \
|
||||
--framerate "${VIDEO_FPS}" \
|
||||
--bitrate "${VIDEO_BITRATE}" \
|
||||
--codec h264 \
|
||||
--profile baseline \
|
||||
--denoise auto \
|
||||
--nopreview \
|
||||
--metering centre \
|
||||
--ev 0.1 \
|
||||
--awb auto \
|
||||
--saturation 0.6 \
|
||||
--brightness 0 \
|
||||
--output - \
|
||||
| "${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-use_wallclock_as_timestamps 1 \
|
||||
-f h264 \
|
||||
-i pipe:0 \
|
||||
-c:v copy \
|
||||
-an \
|
||||
-flush_packets 1 \
|
||||
-f mpegts \
|
||||
"${PUBLISH_URL}"
|
||||
}
|
||||
|
||||
while true; do
|
||||
if run_pipeline; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Video publisher exited, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
if run_pipeline; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Video publisher exited, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
done
|
||||
|
||||
+134
-14
@@ -20,8 +20,8 @@ Options:
|
||||
The script must run from the repository root and as root (sudo). It will:
|
||||
* create system users/groups if needed
|
||||
* install /usr/local/bin/roverd and /etc/roverd.yaml
|
||||
* install /usr/local/bin/video-publisher and its systemd unit
|
||||
* enable roverd.service and video-publisher.service
|
||||
* install /usr/local/bin/video/audio helpers and systemd units
|
||||
* enable roverd.service and media publisher/listener services
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -94,7 +94,39 @@ install_video_deps() {
|
||||
apt-get install -y --no-install-recommends libcamera-apps ffmpeg
|
||||
}
|
||||
|
||||
ensure_user roverd "dialout,gpio,video,render"
|
||||
find_boot_config() {
|
||||
if [[ -f /boot/firmware/config.txt ]]; then
|
||||
printf "/boot/firmware/config.txt"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f /boot/config.txt ]]; then
|
||||
printf "/boot/config.txt"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_pwm_overlay() {
|
||||
local boot_config
|
||||
if ! boot_config="$(find_boot_config)"; then
|
||||
log "WARNING: unable to locate /boot config.txt; please ensure dtoverlay=pwm-2chan is added manually for servo support"
|
||||
return
|
||||
fi
|
||||
if grep -Eq '^\s*dtoverlay=pwm(-2chan)?' "$boot_config"; then
|
||||
log "PWM overlay already present in $boot_config"
|
||||
return
|
||||
fi
|
||||
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
|
||||
cp "$boot_config" "$backup"
|
||||
{
|
||||
echo ""
|
||||
echo "# Added by roverd installer to expose PWM hardware for camera servo control on GPIO12/13 (leaves GPIO18/19 free for I2S)"
|
||||
echo "dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4"
|
||||
} >> "$boot_config"
|
||||
log "Enabled dtoverlay=pwm-2chan on GPIO12/13 in $boot_config (backup at $backup). Reboot required for changes to apply."
|
||||
}
|
||||
|
||||
ensure_user roverd "dialout,gpio,video,render,audio"
|
||||
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
|
||||
log "Installed roverd binary"
|
||||
|
||||
@@ -112,33 +144,121 @@ install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
|
||||
log "Installed roverd systemd unit"
|
||||
|
||||
install_video_deps
|
||||
ensure_pwm_overlay
|
||||
|
||||
# Enable Google AIY v1 sound card, ALSA defaults, and TTS engines
|
||||
install_audio_support() {
|
||||
local boot_config
|
||||
if ! boot_config="$(find_boot_config)"; then
|
||||
log "WARNING: unable to locate /boot config.txt; please enable googlevoicehat-soundcard overlay manually"
|
||||
else
|
||||
# Ensure onboard audio is disabled (prevents card index flapping)
|
||||
if grep -Eq '^\s*dtparam=audio=on\b' "$boot_config"; then
|
||||
log "Disabling onboard audio (dtparam=audio=on -> off) in $boot_config"
|
||||
sed -i 's/^\s*dtparam=audio=on\b/# roverd disabled onboard audio\ndtparam=audio=off/' "$boot_config"
|
||||
fi
|
||||
if ! grep -Eq '^\s*dtparam=audio=off\b' "$boot_config"; then
|
||||
log "Adding dtparam=audio=off to $boot_config"
|
||||
echo "dtparam=audio=off" >> "$boot_config"
|
||||
fi
|
||||
if ! grep -Eq '^\s*dtparam=i2s=on\b' "$boot_config"; then
|
||||
log "Adding dtparam=i2s=on to $boot_config"
|
||||
echo "dtparam=i2s=on" >> "$boot_config"
|
||||
fi
|
||||
if ! grep -Eq '^\s*dtoverlay=googlevoicehat-soundcard\b' "$boot_config"; then
|
||||
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
|
||||
cp "$boot_config" "$backup"
|
||||
{
|
||||
echo ""
|
||||
echo "# Added by roverd installer to enable Google AIY v1 sound card"
|
||||
echo "dtoverlay=googlevoicehat-soundcard"
|
||||
} >> "$boot_config"
|
||||
log "Enabled googlevoicehat-soundcard overlay in $boot_config (backup at $backup). Reboot required."
|
||||
else
|
||||
log "googlevoicehat-soundcard overlay already present in $boot_config"
|
||||
fi
|
||||
fi
|
||||
if [[ -f pi/asound.conf ]]; then
|
||||
install -m 0644 pi/asound.conf /etc/asound.conf
|
||||
log "Installed ALSA config to /etc/asound.conf"
|
||||
alsa_reload_notice=1
|
||||
else
|
||||
log "WARNING: pi/asound.conf missing; skipping ALSA config install"
|
||||
fi
|
||||
|
||||
if [[ "${alsa_reload_notice:-0}" -eq 1 ]]; then
|
||||
log "ALSA config updated; reboot recommended for overlay + audio changes"
|
||||
fi
|
||||
|
||||
log "Installing TTS/audio packages (flite, espeak)..."
|
||||
# check for flite and espeak before installing, and then install them if either is missing
|
||||
if command -v flite >/dev/null 2>&1 && command -v espeak >/dev/null 2>&1; then
|
||||
log "TTS packages flite and espeak already installed; skipping apt install"
|
||||
return
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends flite espeak
|
||||
}
|
||||
|
||||
# Install video publisher assets
|
||||
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
|
||||
log "Installed video-publisher helper"
|
||||
install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service
|
||||
log "Installed video-publisher systemd unit"
|
||||
# Install audio-only publisher assets
|
||||
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
|
||||
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
|
||||
log "Installed audio-only publisher helper + systemd unit"
|
||||
# Install audio-forward listener assets
|
||||
install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/bin/audio-forward-listener
|
||||
install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
|
||||
log "Installed audio-forward listener helper + systemd unit"
|
||||
install -d -o roverd -g roverd /var/lib/roverd
|
||||
if [[ ! -f /var/lib/roverd/video.env ]]; then
|
||||
cat > /var/lib/roverd/video.env <<'ENV'
|
||||
cat > /var/lib/roverd/video.env <<'ENV'
|
||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316
|
||||
VIDEO_WIDTH=1280
|
||||
VIDEO_HEIGHT=720
|
||||
VIDEO_FPS=30
|
||||
VIDEO_BITRATE=3000000
|
||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
VIDEO_BITRATE=2000000
|
||||
AUDIO_ENABLE=0
|
||||
AUDIO_DEVICE=hw:0,0
|
||||
AUDIO_PLAYBACK_DEVICE=forward
|
||||
AUDIO_RATE=48000
|
||||
AUDIO_CHANNELS=2
|
||||
ENV
|
||||
chown roverd:roverd /var/lib/roverd/video.env
|
||||
chmod 0640 /var/lib/roverd/video.env
|
||||
chown roverd:roverd /var/lib/roverd/video.env
|
||||
chmod 0640 /var/lib/roverd/video.env
|
||||
# Create persistent audio FIFO for capture -> publisher
|
||||
FIFO_PATH="/var/lib/roverd/audio.pcm"
|
||||
if [[ -p "$FIFO_PATH" ]]; then
|
||||
chown roverd:audio "$FIFO_PATH"
|
||||
chmod 0660 "$FIFO_PATH"
|
||||
else
|
||||
rm -f "$FIFO_PATH"
|
||||
mkfifo "$FIFO_PATH"
|
||||
chown roverd:audio "$FIFO_PATH"
|
||||
chmod 0660 "$FIFO_PATH"
|
||||
fi
|
||||
# Ensure ALSA config is in place for rovermic device
|
||||
install -m 0644 pi/asound.conf /etc/asound.conf
|
||||
log "Installed ALSA config (/etc/asound.conf)"
|
||||
|
||||
install_audio_support
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable roverd.service
|
||||
systemctl enable video-publisher.service
|
||||
systemctl enable audio-only-publisher.service
|
||||
systemctl enable audio-forward-listener.service
|
||||
if [[ $CONFIG_EXISTS -eq 1 ]]; then
|
||||
systemctl restart roverd.service
|
||||
systemctl restart video-publisher.service
|
||||
log "Restarted roverd + video publisher"
|
||||
systemctl restart audio-only-publisher.service
|
||||
systemctl restart audio-forward-listener.service
|
||||
log "Restarted roverd + media publishers/listener"
|
||||
else
|
||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher"
|
||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher audio-forward-listener"
|
||||
fi
|
||||
|
||||
log "Install complete"
|
||||
|
||||
+3
-1
@@ -10,9 +10,11 @@ build:
|
||||
|
||||
pi-build:
|
||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
|
||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
|
||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/hornverifier ./cmd/hornverifier
|
||||
|
||||
dummy:
|
||||
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
|
||||
|
||||
clean:
|
||||
rm -f $(BIN_DIR)/roverd
|
||||
rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier $(BIN_DIR)/hornverifier
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type AudioLevels struct {
|
||||
HornGain float64
|
||||
TTSGain float64
|
||||
ForwardGain float64
|
||||
}
|
||||
|
||||
func clampAudioGain(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 4 {
|
||||
return 4
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeAudioLevels(v AudioLevels) AudioLevels {
|
||||
v.HornGain = clampAudioGain(v.HornGain)
|
||||
v.TTSGain = clampAudioGain(v.TTSGain)
|
||||
v.ForwardGain = clampAudioGain(v.ForwardGain)
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *WSClient) getAudioLevels() AudioLevels {
|
||||
c.audioMu.RLock()
|
||||
defer c.audioMu.RUnlock()
|
||||
return c.audioLevels
|
||||
}
|
||||
|
||||
func (c *WSClient) setAudioLevels(next AudioLevels) {
|
||||
normalized := normalizeAudioLevels(next)
|
||||
c.audioMu.Lock()
|
||||
c.audioLevels = normalized
|
||||
c.audioMu.Unlock()
|
||||
c.applyAudioLevelsToMixer(normalized)
|
||||
}
|
||||
|
||||
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
levels := c.getAudioLevels()
|
||||
if payload.HornGain != nil {
|
||||
levels.HornGain = clampAudioGain(*payload.HornGain)
|
||||
}
|
||||
if payload.TTSGain != nil {
|
||||
levels.TTSGain = clampAudioGain(*payload.TTSGain)
|
||||
}
|
||||
if payload.ForwardGain != nil {
|
||||
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
|
||||
}
|
||||
c.setAudioLevels(levels)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) applyAudioLevelsToMixer(levels AudioLevels) {
|
||||
c.applyMixerGain("HornMaster", levels.HornGain)
|
||||
c.applyMixerGain("TTSMaster", levels.TTSGain)
|
||||
c.applyMixerGain("ForwardMaster", levels.ForwardGain)
|
||||
}
|
||||
|
||||
func (c *WSClient) applyMixerGain(control string, gain float64) {
|
||||
normalized := clampAudioGain(gain)
|
||||
if normalized <= 0 {
|
||||
if err := c.trySetMixerControl(control, "0%"); err != nil {
|
||||
c.log.Printf("audio-levels: amixer mute %s failed: %v", control, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Convert linear gain to dB, matching softvol max_dB=12.0 in /etc/asound.conf.
|
||||
db := 20.0 * math.Log10(normalized)
|
||||
if db > 12.0 {
|
||||
db = 12.0
|
||||
}
|
||||
if db < -60.0 {
|
||||
db = -60.0
|
||||
}
|
||||
|
||||
// amixer treats a leading "-" value as an option; set via percent to avoid getopt ambiguity.
|
||||
percent := int(math.Round((db + 60.0) / 72.0 * 100.0))
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
percentArg := fmt.Sprintf("%d%%", percent)
|
||||
if err := c.trySetMixerControl(control, percentArg); err != nil {
|
||||
c.log.Printf("audio-levels: amixer set %s=%s failed: %v", control, percentArg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) trySetMixerControl(control, value string) error {
|
||||
// Prefer the active ALSA default route; fall back to card index for compatibility.
|
||||
candidates := [][]string{
|
||||
{"-q", "-D", "default", "sset", control, value},
|
||||
{"-q", "-c", "0", "sset", control, value},
|
||||
}
|
||||
var lastErr error
|
||||
for _, args := range candidates {
|
||||
out, err := exec.Command("amixer", args...).CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("%w (%s)", err, string(out))
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
autoChargeTimeout = 10 * time.Second
|
||||
autoChargeTimeout = 5 * time.Second
|
||||
autoChargeCooldown = 0 * time.Minute
|
||||
sourceHomeBase = 1 << 1
|
||||
)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
||||
)
|
||||
|
||||
type CameraServo struct {
|
||||
cfg CameraServoConfig
|
||||
logger *log.Logger
|
||||
pin rpio.Pin
|
||||
mu sync.Mutex
|
||||
currentAngle float64
|
||||
desiredAngle float64
|
||||
lastMove time.Time
|
||||
moving bool
|
||||
stopCh chan 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")
|
||||
}
|
||||
if err := rpio.Open(); err != nil {
|
||||
return nil, fmt.Errorf("open gpio: %w", err)
|
||||
}
|
||||
|
||||
pin := rpio.Pin(cfg.Pin)
|
||||
pin.Mode(rpio.Pwm)
|
||||
targetClock := cfg.FreqHz * cfg.CycleLen
|
||||
pin.Freq(targetClock)
|
||||
|
||||
servo := &CameraServo{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
pin: pin,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
|
||||
rpio.Close()
|
||||
return nil, err
|
||||
}
|
||||
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg, %d..%d us, invert=%v)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle, cfg.MinPulseUs, cfg.MaxPulseUs, cfg.Invert)
|
||||
return servo, nil
|
||||
}
|
||||
|
||||
func (s *CameraServo) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
|
||||
rpio.Close()
|
||||
if s.stopCh != nil {
|
||||
close(s.stopCh)
|
||||
s.stopCh = nil
|
||||
}
|
||||
s.closed = true
|
||||
}
|
||||
|
||||
func (s *CameraServo) SetAngle(angle float64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.setAngleLocked(angle)
|
||||
}
|
||||
|
||||
func (s *CameraServo) setAngleLocked(angle float64) error {
|
||||
if s.closed {
|
||||
return fmt.Errorf("servo closed")
|
||||
}
|
||||
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
|
||||
s.desiredAngle = clamped
|
||||
limited := s.rateLimitAngleLocked(clamped)
|
||||
s.applyPulseLocked(s.angleToPulse(limited))
|
||||
s.currentAngle = limited
|
||||
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
||||
s.startMoveLoopLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CameraServo) Nudge(delta float64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if delta == 0 {
|
||||
delta = s.cfg.NudgeDegrees
|
||||
}
|
||||
target := s.currentAngle + delta
|
||||
return s.setAngleLocked(target)
|
||||
}
|
||||
|
||||
func (s *CameraServo) SetPulseWidth(micros int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return fmt.Errorf("servo closed")
|
||||
}
|
||||
if !s.cfg.AllowRawPulse {
|
||||
return fmt.Errorf("raw pulse commands disabled")
|
||||
}
|
||||
if micros <= 0 {
|
||||
return fmt.Errorf("pulse width must be > 0")
|
||||
}
|
||||
clampedPulse := clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
||||
targetAngle := s.pulseToAngle(clampedPulse)
|
||||
s.desiredAngle = targetAngle
|
||||
limited := s.rateLimitAngleLocked(targetAngle)
|
||||
s.applyPulseLocked(s.angleToPulse(limited))
|
||||
s.currentAngle = limited
|
||||
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
||||
s.startMoveLoopLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CameraServo) CurrentAngle() float64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.currentAngle
|
||||
}
|
||||
|
||||
func (s *CameraServo) applyPulseLocked(micros int) {
|
||||
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
||||
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
|
||||
}
|
||||
|
||||
func (s *CameraServo) startMoveLoopLocked() {
|
||||
if s.moving || s.stopCh == nil {
|
||||
return
|
||||
}
|
||||
s.moving = true
|
||||
go func() {
|
||||
ticker := time.NewTicker(servoStepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.moving = false
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if math.Abs(s.currentAngle-s.desiredAngle) <= servoAngleEpsilon {
|
||||
s.moving = false
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
limited := s.rateLimitAngleLocked(s.desiredAngle)
|
||||
s.applyPulseLocked(s.angleToPulse(limited))
|
||||
s.currentAngle = limited
|
||||
s.mu.Unlock()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *CameraServo) rateLimitAngleLocked(target float64) float64 {
|
||||
now := time.Now()
|
||||
if s.lastMove.IsZero() {
|
||||
s.lastMove = now
|
||||
}
|
||||
elapsed := now.Sub(s.lastMove).Seconds()
|
||||
if elapsed <= 0 {
|
||||
s.lastMove = now
|
||||
return s.currentAngle
|
||||
}
|
||||
maxElapsed := servoStepInterval.Seconds()
|
||||
if elapsed > maxElapsed {
|
||||
elapsed = maxElapsed
|
||||
}
|
||||
maxDelta := maxServoDegPerSec * elapsed
|
||||
delta := target - s.currentAngle
|
||||
if math.Abs(delta) <= maxDelta {
|
||||
s.lastMove = now
|
||||
return target
|
||||
}
|
||||
if delta > 0 {
|
||||
target = s.currentAngle + maxDelta
|
||||
} else {
|
||||
target = s.currentAngle - maxDelta
|
||||
}
|
||||
s.lastMove = now
|
||||
return target
|
||||
}
|
||||
|
||||
func (s *CameraServo) angleToPulse(angle float64) int {
|
||||
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
|
||||
if totalRange == 0 {
|
||||
return s.cfg.MinPulseUs
|
||||
}
|
||||
norm := (angle - s.cfg.MinAngle) / totalRange
|
||||
norm = math.Max(0, math.Min(1, norm))
|
||||
if s.cfg.Invert {
|
||||
norm = 1 - norm
|
||||
}
|
||||
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
||||
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
|
||||
}
|
||||
|
||||
func (s *CameraServo) pulseToAngle(pulse int) float64 {
|
||||
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
||||
if pulseRange == 0 {
|
||||
return s.cfg.MinAngle
|
||||
}
|
||||
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
|
||||
norm = math.Max(0, math.Min(1, norm))
|
||||
if s.cfg.Invert {
|
||||
norm = 1 - norm
|
||||
}
|
||||
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type CameraServo struct{}
|
||||
|
||||
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
|
||||
return nil, fmt.Errorf("camera servo not supported in dummy build")
|
||||
}
|
||||
|
||||
func (c *CameraServo) Close() {}
|
||||
|
||||
func (c *CameraServo) SetAngle(angle float64) error {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
|
||||
func (c *CameraServo) Nudge(delta float64) error {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
|
||||
func (c *CameraServo) SetPulseWidth(micros int) error {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
|
||||
func (c *CameraServo) CurrentAngle() float64 {
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package roverd
|
||||
|
||||
func clampInt(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const twoPi = 2 * math.Pi
|
||||
|
||||
func main() {
|
||||
var (
|
||||
device = flag.String("device", "", "ALSA device (empty = default)")
|
||||
rate = flag.Int("rate", 48000, "Sample rate in Hz")
|
||||
channels = flag.Int("channels", 1, "Number of audio channels")
|
||||
duration = flag.Duration("duration", 2*time.Second, "Total horn duration")
|
||||
freqsRaw = flag.String("freqs", "440,550,660", "Comma-separated frequencies in Hz")
|
||||
volume = flag.Float64("volume", 0.25, "Output volume 0.0-1.0")
|
||||
attack = flag.Duration("attack", 20*time.Millisecond, "Attack time")
|
||||
release = flag.Duration("release", 60*time.Millisecond, "Release time")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *rate <= 0 {
|
||||
log.Fatalf("rate must be > 0 (got %d)", *rate)
|
||||
}
|
||||
if *channels <= 0 {
|
||||
log.Fatalf("channels must be > 0 (got %d)", *channels)
|
||||
}
|
||||
if *duration <= 0 {
|
||||
log.Fatalf("duration must be > 0 (got %s)", *duration)
|
||||
}
|
||||
if *volume <= 0 || *volume > 1.0 {
|
||||
log.Fatalf("volume must be within (0,1] (got %.3f)", *volume)
|
||||
}
|
||||
if *attack < 0 || *release < 0 {
|
||||
log.Fatalf("attack/release must be >= 0")
|
||||
}
|
||||
|
||||
freqs, err := parseFreqs(*freqsRaw)
|
||||
if err != nil {
|
||||
log.Fatalf("parse freqs: %v", err)
|
||||
}
|
||||
if len(freqs) == 0 {
|
||||
log.Fatal("no frequencies provided")
|
||||
}
|
||||
|
||||
if *attack+*release > *duration {
|
||||
log.Fatalf("attack+release must be <= duration (%s + %s > %s)", *attack, *release, *duration)
|
||||
}
|
||||
|
||||
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", *channels), "-r", fmt.Sprintf("%d", *rate), "-t", "raw"}
|
||||
if *device != "" {
|
||||
args = append(args, "-D", *device)
|
||||
}
|
||||
cmd := exec.Command("aplay", args...)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
log.Fatalf("aplay stdin: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("start aplay: %v", err)
|
||||
}
|
||||
|
||||
writer := bufio.NewWriterSize(stdin, 32*1024)
|
||||
if err := synthChord(writer, freqs, *rate, *channels, *duration, *volume, *attack, *release); err != nil {
|
||||
_ = stdin.Close()
|
||||
_ = cmd.Wait()
|
||||
log.Fatalf("synth: %v", err)
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
_ = stdin.Close()
|
||||
_ = cmd.Wait()
|
||||
log.Fatalf("flush: %v", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
_ = cmd.Wait()
|
||||
log.Fatalf("close stdin: %v", err)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Fatalf("aplay failed: %v", err)
|
||||
}
|
||||
log.Print("Horn verification complete")
|
||||
}
|
||||
|
||||
func parseFreqs(raw string) ([]float64, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(trimmed, ",")
|
||||
freqs := make([]float64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(part, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid freq %q", part)
|
||||
}
|
||||
if value <= 0 {
|
||||
return nil, fmt.Errorf("freq must be > 0 (got %.3f)", value)
|
||||
}
|
||||
freqs = append(freqs, value)
|
||||
}
|
||||
return freqs, nil
|
||||
}
|
||||
|
||||
func synthChord(writer *bufio.Writer, freqs []float64, rate, channels int, duration time.Duration, volume float64, attack, release time.Duration) error {
|
||||
totalFrames := int(float64(rate) * duration.Seconds())
|
||||
if totalFrames <= 0 {
|
||||
return fmt.Errorf("duration too short")
|
||||
}
|
||||
|
||||
phase := make([]float64, len(freqs))
|
||||
increment := make([]float64, len(freqs))
|
||||
for i, f := range freqs {
|
||||
increment[i] = twoPi * f / float64(rate)
|
||||
}
|
||||
|
||||
attackFrames := int(float64(rate) * attack.Seconds())
|
||||
releaseFrames := int(float64(rate) * release.Seconds())
|
||||
steadyFrames := totalFrames - attackFrames - releaseFrames
|
||||
|
||||
framesPerChunk := 512
|
||||
buf := make([]byte, framesPerChunk*channels*2)
|
||||
sampleIndex := 0
|
||||
scale := volume / float64(len(freqs))
|
||||
|
||||
for framesLeft := totalFrames; framesLeft > 0; {
|
||||
framesNow := framesPerChunk
|
||||
if framesLeft < framesNow {
|
||||
framesNow = framesLeft
|
||||
}
|
||||
for i := 0; i < framesNow; i++ {
|
||||
env := envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames)
|
||||
sample := 0.0
|
||||
for j := range freqs {
|
||||
sample += sawFromPhase(phase[j])
|
||||
phase[j] += increment[j]
|
||||
if phase[j] > twoPi {
|
||||
phase[j] -= twoPi
|
||||
}
|
||||
}
|
||||
sample *= scale * env
|
||||
if sample > 1.0 {
|
||||
sample = 1.0
|
||||
} else if sample < -1.0 {
|
||||
sample = -1.0
|
||||
}
|
||||
intSample := int16(sample * math.MaxInt16)
|
||||
offset := i * channels * 2
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
||||
}
|
||||
sampleIndex++
|
||||
}
|
||||
if _, err := writer.Write(buf[:framesNow*channels*2]); err != nil {
|
||||
return err
|
||||
}
|
||||
framesLeft -= framesNow
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames int) float64 {
|
||||
if attackFrames > 0 && sampleIndex < attackFrames {
|
||||
return float64(sampleIndex) / float64(attackFrames)
|
||||
}
|
||||
if releaseFrames > 0 && sampleIndex >= attackFrames+steadyFrames {
|
||||
relIndex := sampleIndex - (attackFrames + steadyFrames)
|
||||
return float64(releaseFrames-relIndex) / float64(releaseFrames)
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
func sawFromPhase(phase float64) float64 {
|
||||
return 2.0*(phase/twoPi) - 1.0
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil {
|
||||
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
|
||||
log.Fatalf("prepare media env: %v", err)
|
||||
}
|
||||
|
||||
@@ -55,15 +55,33 @@ func main() {
|
||||
|
||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
||||
|
||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
|
||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
|
||||
if mediaSupervisor != nil {
|
||||
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()
|
||||
}
|
||||
|
||||
var nightVision *roverd.NightVisionLight
|
||||
if cfg.NightVision.Enabled {
|
||||
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init night vision: %v", err)
|
||||
}
|
||||
defer nightVision.Close()
|
||||
}
|
||||
|
||||
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
||||
go autoCharge.Run(ctx, sensorSamples)
|
||||
|
||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, logger)
|
||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
|
||||
|
||||
retryDelay := time.Second
|
||||
for ctx.Err() == nil {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
pinNum = flag.Int("pin", 19, "BCM pin connected to the servo signal line")
|
||||
freqHz = flag.Int("freq", 50, "Servo PWM frequency in Hz")
|
||||
cycleLen = flag.Int("cycle", 20000, "PWM cycle length (counts per period)")
|
||||
minPulse = flag.Int("min", 900, "Minimum pulse width in microseconds")
|
||||
maxPulse = flag.Int("max", 2100, "Maximum pulse width in microseconds")
|
||||
stepPulse = flag.Int("step", 100, "Pulse width increment in microseconds when sweeping")
|
||||
sweeps = flag.Int("sweeps", 2, "How many full min→max→min sweeps to perform")
|
||||
pause = flag.Duration("pause", 150*time.Millisecond, "Delay between pulse adjustments")
|
||||
holdPulse = flag.Int("hold", 0, "Pulse width to hold before exiting (0 = midpoint of min/max)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *freqHz <= 0 || *cycleLen <= 0 {
|
||||
log.Fatalf("invalid freq (%d) or cycle (%d)", *freqHz, *cycleLen)
|
||||
}
|
||||
if *minPulse <= 0 || *maxPulse <= 0 || *minPulse >= *maxPulse {
|
||||
log.Fatalf("invalid min/max pulses (%d/%d)", *minPulse, *maxPulse)
|
||||
}
|
||||
if *stepPulse <= 0 {
|
||||
log.Fatalf("step must be > 0 (got %d)", *stepPulse)
|
||||
}
|
||||
if *pause <= 0 {
|
||||
log.Fatalf("pause must be > 0 (got %s)", pause)
|
||||
}
|
||||
if *sweeps < 0 {
|
||||
log.Fatalf("sweeps must be >= 0 (got %d)", *sweeps)
|
||||
}
|
||||
|
||||
if err := rpio.Open(); err != nil {
|
||||
log.Fatalf("open gpio: %v", err)
|
||||
}
|
||||
defer rpio.Close()
|
||||
|
||||
pin := rpio.Pin(*pinNum)
|
||||
pin.Mode(rpio.Pwm)
|
||||
|
||||
targetClock := *freqHz * *cycleLen
|
||||
pin.Freq(targetClock)
|
||||
log.Printf("Configured PWM pin %d at %d Hz (clock=%d Hz, cycle=%d)", *pinNum, *freqHz, targetClock, *cycleLen)
|
||||
|
||||
setPulse := func(us int) {
|
||||
clamped := clamp(us, *minPulse, *maxPulse)
|
||||
pin.DutyCycle(uint32(clamped), uint32(*cycleLen))
|
||||
log.Printf("pulse -> %dµs", clamped)
|
||||
}
|
||||
|
||||
mid := (*minPulse + *maxPulse) / 2
|
||||
setPulse(mid)
|
||||
|
||||
runSweep := func() {
|
||||
for pulse := *minPulse; pulse <= *maxPulse; pulse += *stepPulse {
|
||||
setPulse(pulse)
|
||||
time.Sleep(*pause)
|
||||
}
|
||||
for pulse := *maxPulse - *stepPulse; pulse >= *minPulse; pulse -= *stepPulse {
|
||||
setPulse(pulse)
|
||||
time.Sleep(*pause)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < *sweeps; i++ {
|
||||
log.Printf("Sweep %d/%d", i+1, *sweeps)
|
||||
runSweep()
|
||||
}
|
||||
|
||||
finalPulse := *holdPulse
|
||||
if finalPulse <= 0 {
|
||||
finalPulse = mid
|
||||
}
|
||||
setPulse(finalPulse)
|
||||
log.Printf("Holding at %dµs", clamp(finalPulse, *minPulse, *maxPulse))
|
||||
log.Print("Servo verification complete")
|
||||
}
|
||||
|
||||
func clamp(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
+63
-5
@@ -1,11 +1,17 @@
|
||||
package roverd
|
||||
|
||||
type helloMessage struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Battery BatteryConfig `json:"battery"`
|
||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||
Media MediaConfig `json:"media"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
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"`
|
||||
NightVision NightVisionConfig `json:"nightVision"`
|
||||
Private PrivateConfig `json:"private"`
|
||||
}
|
||||
|
||||
type sensorMessage struct {
|
||||
@@ -22,6 +28,13 @@ type inboundMessage struct {
|
||||
Raw string `json:"raw,omitempty"`
|
||||
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
|
||||
Media *mediaCommand `json:"media,omitempty"`
|
||||
Servo *servoPayload `json:"servo,omitempty"`
|
||||
TTS *ttsPayload `json:"tts,omitempty"`
|
||||
Horn *hornPayload `json:"horn,omitempty"`
|
||||
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
|
||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||
Song *songPayload `json:"song,omitempty"`
|
||||
Reboot *rebootPayload `json:"reboot,omitempty"`
|
||||
}
|
||||
|
||||
type driveDirectPayload struct {
|
||||
@@ -43,6 +56,51 @@ type mediaCommand struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type servoPayload struct {
|
||||
Angle *float64 `json:"angle,omitempty"`
|
||||
Nudge *float64 `json:"nudge,omitempty"`
|
||||
PulseUs *int `json:"pulseUs,omitempty"`
|
||||
}
|
||||
|
||||
type ttsPayload struct {
|
||||
Text string `json:"text"`
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Voice string `json:"voice,omitempty"`
|
||||
Pitch int `json:"pitch,omitempty"`
|
||||
Speak bool `json:"speak,omitempty"`
|
||||
}
|
||||
|
||||
type hornPayload struct {
|
||||
Action string `json:"action"`
|
||||
Waveform string `json:"waveform,omitempty"`
|
||||
Freqs []float64 `json:"freqs,omitempty"`
|
||||
}
|
||||
|
||||
type audioLevelsPayload struct {
|
||||
HornGain *float64 `json:"hornGain,omitempty"`
|
||||
TTSGain *float64 `json:"ttsGain,omitempty"`
|
||||
ForwardGain *float64 `json:"forwardGain,omitempty"`
|
||||
}
|
||||
|
||||
type nightVisionPayload struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type songPayload struct {
|
||||
Slot *int `json:"slot,omitempty"`
|
||||
Notes []songNote `json:"notes"`
|
||||
Loop bool `json:"loop,omitempty"`
|
||||
}
|
||||
|
||||
type songNote struct {
|
||||
Note int `json:"note"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
type rebootPayload struct {
|
||||
DelayMs int `json:"delayMs,omitempty"`
|
||||
}
|
||||
|
||||
type ackMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
|
||||
+331
-36
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -53,27 +55,108 @@ type BatteryConfig struct {
|
||||
Urgent int `yaml:"urgent"`
|
||||
}
|
||||
|
||||
type AudioConfig struct {
|
||||
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
||||
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
|
||||
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"`
|
||||
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
|
||||
Channels int `yaml:"channels" json:"channels,omitempty"`
|
||||
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
|
||||
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
|
||||
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
|
||||
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
|
||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
||||
}
|
||||
|
||||
type HornConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Volume float64 `yaml:"volume" json:"-"`
|
||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||
Channels int `yaml:"channels" json:"-"`
|
||||
Device string `yaml:"device" json:"-"`
|
||||
SineGain float64 `yaml:"sineGain" json:"-"`
|
||||
SawGain float64 `yaml:"sawGain" json:"-"`
|
||||
MaxDuration Duration `yaml:"maxDuration" json:"-"`
|
||||
}
|
||||
|
||||
type MediaConfig struct {
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
Manage bool `yaml:"manage"`
|
||||
Service string `yaml:"service"`
|
||||
HealthURL string `yaml:"healthUrl"`
|
||||
HealthInterval Duration `yaml:"healthInterval"`
|
||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
Manage bool `yaml:"manage"`
|
||||
ManageAudio bool `yaml:"manageAudio"`
|
||||
Service string `yaml:"service"`
|
||||
AudioService string `yaml:"audioService"`
|
||||
HealthURL string `yaml:"healthUrl"`
|
||||
HealthInterval Duration `yaml:"healthInterval"`
|
||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
||||
}
|
||||
|
||||
type CameraServoConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Pin int `yaml:"pin" json:"pin"`
|
||||
FreqHz int `yaml:"freqHz" json:"freqHz"`
|
||||
CycleLen int `yaml:"cycleLen" json:"cycleLen"`
|
||||
MinPulseUs int `yaml:"minPulseUs" json:"minPulseUs"`
|
||||
MaxPulseUs int `yaml:"maxPulseUs" json:"maxPulseUs"`
|
||||
MinAngle float64 `yaml:"minAngle" json:"minAngle"`
|
||||
MaxAngle float64 `yaml:"maxAngle" json:"maxAngle"`
|
||||
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
|
||||
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
|
||||
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
|
||||
Invert bool `yaml:"invert" json:"invert"`
|
||||
}
|
||||
|
||||
type NightVisionConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
|
||||
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
|
||||
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
||||
}
|
||||
|
||||
type AutoSideBrushConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Speed int `yaml:"speed"`
|
||||
}
|
||||
|
||||
type PrivateConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Safety PrivateSafetyConfig `yaml:"safety" json:"safety"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Name string `yaml:"name"`
|
||||
ServerURL string `yaml:"serverUrl"`
|
||||
Serial SerialConfig `yaml:"serial"`
|
||||
BRC BRCConfig `yaml:"brc"`
|
||||
Battery BatteryConfig `yaml:"battery"`
|
||||
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
||||
Media MediaConfig `yaml:"media"`
|
||||
Name string `yaml:"name"`
|
||||
Color string `yaml:"color" json:"color,omitempty"`
|
||||
ServerURL string `yaml:"serverUrl"`
|
||||
Serial SerialConfig `yaml:"serial"`
|
||||
BRC BRCConfig `yaml:"brc"`
|
||||
Battery BatteryConfig `yaml:"battery"`
|
||||
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
||||
Media MediaConfig `yaml:"media"`
|
||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||
Audio AudioConfig `yaml:"audio"`
|
||||
Horn HornConfig `yaml:"horn"`
|
||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
|
||||
Private PrivateConfig `yaml:"private" json:"private"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
@@ -84,7 +167,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||
cfg := Config{
|
||||
MaxWheelMMs: 500,
|
||||
BRC: BRCConfig{
|
||||
GPIOPin: -1,
|
||||
GPIOPin: 4,
|
||||
GPIOChip: "gpiochip0",
|
||||
PulseEvery: Duration{
|
||||
Duration: time.Minute,
|
||||
@@ -96,10 +179,65 @@ func LoadConfig(path string) (*Config, error) {
|
||||
Media: MediaConfig{
|
||||
PublishPort: 9000,
|
||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||
VideoWidth: 1280,
|
||||
VideoHeight: 720,
|
||||
VideoFPS: 30,
|
||||
VideoBitrate: 3000000,
|
||||
VideoBitrate: 2000000,
|
||||
},
|
||||
CameraServo: CameraServoConfig{
|
||||
Pin: 12,
|
||||
FreqHz: 50,
|
||||
CycleLen: 20000,
|
||||
MinPulseUs: 900,
|
||||
MaxPulseUs: 2100,
|
||||
MinAngle: -15,
|
||||
MaxAngle: 30,
|
||||
HomeAngle: 0,
|
||||
NudgeDegrees: 2,
|
||||
},
|
||||
Audio: AudioConfig{
|
||||
CaptureEnabled: false,
|
||||
CaptureDevice: "rovermic",
|
||||
PlaybackDevice: "forward",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
Bitrate: 24000,
|
||||
TTSEnabled: false,
|
||||
DefaultEngine: "flite",
|
||||
DefaultVoice: "rms",
|
||||
DefaultPitch: 50,
|
||||
},
|
||||
Horn: HornConfig{
|
||||
Enabled: false,
|
||||
Volume: 0.25,
|
||||
SampleRate: 48000,
|
||||
Channels: 1,
|
||||
SineGain: 1.0,
|
||||
SawGain: 0.7,
|
||||
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
|
||||
},
|
||||
NightVision: NightVisionConfig{
|
||||
Enabled: true,
|
||||
GPIOPin: 22,
|
||||
GPIOChip: "gpiochip0",
|
||||
InitialOn: true,
|
||||
},
|
||||
AutoSideBrush: AutoSideBrushConfig{
|
||||
Enabled: true,
|
||||
Speed: 20,
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
@@ -108,6 +246,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if cfg.Name == "" {
|
||||
return nil, errors.New("missing name")
|
||||
}
|
||||
normalizedColor, err := normalizeHexColor(cfg.Color)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Color = normalizedColor
|
||||
if cfg.ServerURL == "" {
|
||||
return nil, errors.New("missing serverUrl")
|
||||
}
|
||||
@@ -129,15 +272,6 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
||||
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||
}
|
||||
if cfg.Media.VideoWidth <= 0 {
|
||||
cfg.Media.VideoWidth = 1280
|
||||
}
|
||||
if cfg.Media.VideoHeight <= 0 {
|
||||
cfg.Media.VideoHeight = 720
|
||||
}
|
||||
if cfg.Media.VideoFPS <= 0 {
|
||||
cfg.Media.VideoFPS = 30
|
||||
}
|
||||
if cfg.Media.VideoBitrate <= 0 {
|
||||
cfg.Media.VideoBitrate = 3000000
|
||||
}
|
||||
@@ -151,12 +285,160 @@ func LoadConfig(path string) (*Config, error) {
|
||||
}
|
||||
cfg.Media.PublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioPublishURL == "" {
|
||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioPublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioForwardURL == "" {
|
||||
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioForwardURL = derived
|
||||
}
|
||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||
}
|
||||
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
||||
return nil, fmt.Errorf("nightVision: %w", err)
|
||||
}
|
||||
validateAudioConfig(&cfg.Audio)
|
||||
validateHornConfig(&cfg.Horn)
|
||||
validateAutoSideBrushConfig(&cfg.AutoSideBrush)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func derivePublishURL(serverURL, roverName string, port int) (string, error) {
|
||||
if roverName == "" {
|
||||
return "", errors.New("missing rover name for publishUrl")
|
||||
func validateServoConfig(cfg *CameraServoConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if cfg.Pin <= 0 {
|
||||
return errors.New("pin must be > 0")
|
||||
}
|
||||
if cfg.FreqHz <= 0 {
|
||||
return errors.New("freqHz must be > 0")
|
||||
}
|
||||
if cfg.CycleLen <= 0 {
|
||||
return errors.New("cycleLen must be > 0")
|
||||
}
|
||||
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 {
|
||||
return errors.New("minPulseUs/maxPulseUs invalid")
|
||||
}
|
||||
if cfg.MinPulseUs == cfg.MaxPulseUs {
|
||||
return errors.New("minPulseUs/maxPulseUs cannot be equal")
|
||||
}
|
||||
if cfg.MinPulseUs > cfg.MaxPulseUs {
|
||||
cfg.MinPulseUs, cfg.MaxPulseUs = cfg.MaxPulseUs, cfg.MinPulseUs
|
||||
cfg.Invert = !cfg.Invert
|
||||
}
|
||||
if cfg.MinAngle >= cfg.MaxAngle {
|
||||
return errors.New("minAngle must be less than maxAngle")
|
||||
}
|
||||
cfg.HomeAngle = clampFloat(cfg.HomeAngle, cfg.MinAngle, cfg.MaxAngle)
|
||||
if cfg.NudgeDegrees <= 0 {
|
||||
cfg.NudgeDegrees = 2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampFloat(value, min, max float64) float64 {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validateAudioConfig(cfg *AudioConfig) {
|
||||
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
|
||||
cfg.CaptureDevice = "hw:0,0"
|
||||
}
|
||||
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
|
||||
cfg.PlaybackDevice = "forward"
|
||||
}
|
||||
if cfg.SampleRate <= 0 {
|
||||
cfg.SampleRate = 48000
|
||||
}
|
||||
if cfg.Channels <= 0 {
|
||||
cfg.Channels = 2
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 64000
|
||||
}
|
||||
if cfg.DefaultEngine == "" {
|
||||
cfg.DefaultEngine = "flite"
|
||||
}
|
||||
if cfg.DefaultVoice == "" {
|
||||
cfg.DefaultVoice = "rms"
|
||||
}
|
||||
if cfg.DefaultPitch <= 0 {
|
||||
cfg.DefaultPitch = 50
|
||||
}
|
||||
}
|
||||
|
||||
func validateHornConfig(cfg *HornConfig) {
|
||||
if cfg.Volume <= 0 {
|
||||
cfg.Volume = 0.25
|
||||
}
|
||||
if cfg.Volume > 1 {
|
||||
cfg.Volume = 1
|
||||
}
|
||||
if cfg.SampleRate <= 0 {
|
||||
cfg.SampleRate = 48000
|
||||
}
|
||||
if cfg.Channels <= 0 {
|
||||
cfg.Channels = 1
|
||||
}
|
||||
if cfg.SineGain <= 0 {
|
||||
cfg.SineGain = 1.0
|
||||
}
|
||||
if cfg.SawGain <= 0 {
|
||||
cfg.SawGain = 0.7
|
||||
}
|
||||
if cfg.MaxDuration.Duration <= 0 {
|
||||
cfg.MaxDuration = Duration{Duration: 1200 * time.Millisecond}
|
||||
}
|
||||
}
|
||||
|
||||
func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if cfg.GPIOPin <= 0 {
|
||||
return errors.New("gpioPin must be > 0")
|
||||
}
|
||||
if cfg.GPIOChip == "" {
|
||||
cfg.GPIOChip = "gpiochip0"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
|
||||
if cfg.Speed == 0 {
|
||||
return
|
||||
}
|
||||
cfg.Speed = clampInt(cfg.Speed, -127, 127)
|
||||
}
|
||||
|
||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||
return deriveSRTURL(serverURL, streamName, port, "publish")
|
||||
}
|
||||
|
||||
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
||||
return deriveSRTURL(serverURL, streamName, port, "request")
|
||||
}
|
||||
|
||||
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
|
||||
if streamName == "" {
|
||||
return "", errors.New("missing stream name for publishUrl")
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "publish"
|
||||
}
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
@@ -169,6 +451,19 @@ func derivePublishURL(serverURL, roverName string, port int) (string, error) {
|
||||
if port <= 0 {
|
||||
port = 9000
|
||||
}
|
||||
streamName := url.PathEscape(roverName)
|
||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316", host, port, streamName), nil
|
||||
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
|
||||
}
|
||||
|
||||
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||
|
||||
func normalizeHexColor(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !hexColorRe.MatchString(trimmed) {
|
||||
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
|
||||
}
|
||||
return strings.ToUpper(trimmed), nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ module multiroombarover/pi/roverd
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/stianeikeland/go-rpio/v4 v4.6.0
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||
github.com/warthog618/go-gpiocdev v0.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stianeikeland/go-rpio/v4 v4.6.0 h1:eAJgtw3jTtvn/CqwbC82ntcS+dtzUTgo5qlZKe677EY=
|
||||
github.com/stianeikeland/go-rpio/v4 v4.6.0/go.mod h1:A3GvHxC1Om5zaId+HqB3HKqx4K/AqeckxB7qRjxMK7o=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
hornAttack = 20 * time.Millisecond
|
||||
hornRelease = 60 * time.Millisecond
|
||||
)
|
||||
|
||||
type HornSynth struct {
|
||||
cfg HornConfig
|
||||
log *log.Logger
|
||||
gain float64
|
||||
|
||||
mu sync.Mutex
|
||||
stop chan struct{}
|
||||
active bool
|
||||
proc *exec.Cmd
|
||||
}
|
||||
|
||||
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
|
||||
return &HornSynth{
|
||||
cfg: cfg,
|
||||
log: logger,
|
||||
gain: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HornSynth) SetGlobalGain(gain float64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.gain = clampAudioGain(gain)
|
||||
}
|
||||
|
||||
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("horn payload required")
|
||||
}
|
||||
action := strings.ToLower(strings.TrimSpace(payload.Action))
|
||||
switch action {
|
||||
case "start", "on", "honk":
|
||||
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
|
||||
if waveform != "sine" && waveform != "saw" {
|
||||
waveform = "saw"
|
||||
}
|
||||
freqs := sanitizeHornFreqs(payload.Freqs)
|
||||
if len(freqs) == 0 {
|
||||
h.Stop()
|
||||
return nil
|
||||
}
|
||||
return h.Start(waveform, freqs)
|
||||
case "stop", "off":
|
||||
h.Stop()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported horn action: %s", payload.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HornSynth) Start(waveform string, freqs []float64) error {
|
||||
h.mu.Lock()
|
||||
if h.active {
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
h.stop = stop
|
||||
h.active = true
|
||||
h.mu.Unlock()
|
||||
|
||||
go h.run(waveform, freqs, stop)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HornSynth) Stop() {
|
||||
h.mu.Lock()
|
||||
if !h.active {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
stop := h.stop
|
||||
proc := h.proc
|
||||
h.stop = nil
|
||||
h.proc = nil
|
||||
h.active = false
|
||||
h.mu.Unlock()
|
||||
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
if proc != nil && proc.Process != nil {
|
||||
_ = proc.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}) {
|
||||
rate := h.cfg.SampleRate
|
||||
if rate <= 0 {
|
||||
rate = 48000
|
||||
}
|
||||
channels := h.cfg.Channels
|
||||
if channels <= 0 {
|
||||
channels = 1
|
||||
}
|
||||
volume := h.cfg.Volume
|
||||
if volume <= 0 {
|
||||
volume = 0.25
|
||||
}
|
||||
if volume > 1 {
|
||||
volume = 1
|
||||
}
|
||||
h.mu.Lock()
|
||||
gain := h.gain
|
||||
h.mu.Unlock()
|
||||
volume *= gain
|
||||
|
||||
device := strings.TrimSpace(h.cfg.Device)
|
||||
if device == "" {
|
||||
device = "horn"
|
||||
}
|
||||
args := []string{"-q", "-D", device, "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
|
||||
cmd := exec.Command("aplay", args...)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
h.log.Printf("horn: aplay stdin failed: %v", err)
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
h.log.Printf("horn: aplay start failed: %v", err)
|
||||
_ = stdin.Close()
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if h.active {
|
||||
h.proc = cmd
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
writer := bufio.NewWriterSize(stdin, 32*1024)
|
||||
maxFrames := 0
|
||||
if h.cfg.MaxDuration.Duration > 0 {
|
||||
maxFrames = int(float64(rate) * h.cfg.MaxDuration.Duration.Seconds())
|
||||
}
|
||||
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, maxFrames, stop); err != nil {
|
||||
h.log.Printf("horn: synth failed: %v", err)
|
||||
}
|
||||
_ = writer.Flush()
|
||||
_ = stdin.Close()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
h.log.Printf("horn: aplay exit: %v", err)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if h.proc == cmd {
|
||||
h.proc = nil
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, maxFrames int, stop <-chan struct{}) error {
|
||||
phase := make([]float64, len(freqs))
|
||||
increment := make([]float64, len(freqs))
|
||||
for i, f := range freqs {
|
||||
increment[i] = 2 * math.Pi * f / float64(rate)
|
||||
}
|
||||
attackFrames := int(float64(rate) * hornAttack.Seconds())
|
||||
releaseFrames := int(float64(rate) * hornRelease.Seconds())
|
||||
framesPerChunk := 512
|
||||
buf := make([]byte, framesPerChunk*channels*2)
|
||||
scale := volume / float64(len(freqs))
|
||||
if waveform == "sine" {
|
||||
scale *= h.cfg.SineGain
|
||||
} else {
|
||||
scale *= h.cfg.SawGain
|
||||
}
|
||||
|
||||
stopRequested := false
|
||||
releaseStart := -1
|
||||
sampleIndex := 0
|
||||
|
||||
for {
|
||||
if !stopRequested {
|
||||
select {
|
||||
case <-stop:
|
||||
stopRequested = true
|
||||
releaseStart = sampleIndex
|
||||
default:
|
||||
}
|
||||
}
|
||||
for i := 0; i < framesPerChunk; i++ {
|
||||
if maxFrames > 0 && sampleIndex >= maxFrames && !stopRequested {
|
||||
stopRequested = true
|
||||
releaseStart = sampleIndex
|
||||
}
|
||||
env := 1.0
|
||||
if attackFrames > 0 && sampleIndex < attackFrames {
|
||||
env = float64(sampleIndex) / float64(attackFrames)
|
||||
} else if stopRequested && releaseFrames > 0 {
|
||||
relIndex := sampleIndex - releaseStart
|
||||
if relIndex >= releaseFrames {
|
||||
return nil
|
||||
}
|
||||
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
|
||||
} else if stopRequested {
|
||||
return nil
|
||||
}
|
||||
|
||||
sample := 0.0
|
||||
for j := range freqs {
|
||||
switch waveform {
|
||||
case "sine":
|
||||
sample += math.Sin(phase[j])
|
||||
default:
|
||||
sample += sawFromPhase(phase[j])
|
||||
}
|
||||
phase[j] += increment[j]
|
||||
if phase[j] > 2*math.Pi {
|
||||
phase[j] -= 2 * math.Pi
|
||||
}
|
||||
}
|
||||
sample *= scale * env
|
||||
if sample > 1.0 {
|
||||
sample = 1.0
|
||||
} else if sample < -1.0 {
|
||||
sample = -1.0
|
||||
}
|
||||
intSample := int16(sample * math.MaxInt16)
|
||||
offset := i * channels * 2
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
||||
}
|
||||
sampleIndex++
|
||||
}
|
||||
if _, err := writer.Write(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeHornFreqs(freqs []float64) []float64 {
|
||||
if len(freqs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, 0, 4)
|
||||
for _, f := range freqs {
|
||||
if len(out) >= 4 {
|
||||
break
|
||||
}
|
||||
if f <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sawFromPhase(phase float64) float64 {
|
||||
return 2.0*(phase/(2*math.Pi)) - 1.0
|
||||
}
|
||||
+49
-8
@@ -9,24 +9,65 @@ import (
|
||||
|
||||
const publisherEnvPath = "/var/lib/roverd/video.env"
|
||||
|
||||
func UpdatePublisherEnv(cfg MediaConfig) error {
|
||||
if cfg.PublishURL == "" {
|
||||
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||
if media.PublishURL == "" {
|
||||
return fmt.Errorf("media publishUrl missing")
|
||||
}
|
||||
if cfg.VideoWidth <= 0 || cfg.VideoHeight <= 0 || cfg.VideoFPS <= 0 || cfg.VideoBitrate <= 0 {
|
||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||
return fmt.Errorf("audio publishUrl missing")
|
||||
}
|
||||
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", cfg.PublishURL)
|
||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth)
|
||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight)
|
||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS)
|
||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate)
|
||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
|
||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||
}
|
||||
if media.AudioForwardURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
||||
}
|
||||
if media.VideoWidth > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||
}
|
||||
if media.VideoHeight > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
||||
}
|
||||
if media.VideoFPS > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
||||
}
|
||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||
audioDevice := audio.CaptureDevice
|
||||
if audioDevice == "" || audioDevice == "rovermic" {
|
||||
audioDevice = "hw:0,0"
|
||||
}
|
||||
if audio.SampleRate <= 0 {
|
||||
audio.SampleRate = 48000
|
||||
}
|
||||
if audio.Channels <= 0 {
|
||||
audio.Channels = 2
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
|
||||
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
|
||||
playbackDevice := audio.PlaybackDevice
|
||||
if playbackDevice == "" {
|
||||
playbackDevice = "forward"
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
|
||||
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
|
||||
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
|
||||
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ import (
|
||||
|
||||
type MediaSupervisor struct {
|
||||
cfg MediaConfig
|
||||
audio AudioConfig
|
||||
logger *log.Logger
|
||||
client *http.Client
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
||||
if err := UpdatePublisherEnv(cfg); err != nil {
|
||||
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
|
||||
if err := UpdatePublisherEnv(cfg, audio); err != nil {
|
||||
logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if !cfg.Manage || cfg.Service == "" {
|
||||
@@ -35,6 +36,7 @@ func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
||||
}
|
||||
return &MediaSupervisor{
|
||||
cfg: cfg,
|
||||
audio: audio,
|
||||
logger: logger,
|
||||
client: client,
|
||||
checkInterval: interval,
|
||||
@@ -45,7 +47,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
m.logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if m.cfg.HealthURL == "" || m.client == nil {
|
||||
@@ -76,7 +78,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
|
||||
if m == nil {
|
||||
return errors.New("media supervisor disabled")
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
type NightVisionLight struct {
|
||||
cfg NightVisionConfig
|
||||
logger *log.Logger
|
||||
line *gpiocdev.Line
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("night vision disabled")
|
||||
}
|
||||
chip := cfg.GPIOChip
|
||||
if chip == "" {
|
||||
chip = "gpiochip0"
|
||||
}
|
||||
initial := 0
|
||||
if cfg.InitialOn {
|
||||
initial = 1
|
||||
}
|
||||
line, err := gpiocdev.RequestLine(
|
||||
chip,
|
||||
cfg.GPIOPin,
|
||||
gpiocdev.AsOutput(initial),
|
||||
gpiocdev.WithConsumer("roverd-nightvision"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpio request: %w", err)
|
||||
}
|
||||
nv := &NightVisionLight{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
line: line,
|
||||
on: cfg.InitialOn,
|
||||
}
|
||||
logger.Printf("night vision LED on GPIO %d (initial=%v)", cfg.GPIOPin, cfg.InitialOn)
|
||||
return nv, nil
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return
|
||||
}
|
||||
_ = n.line.SetValue(boolToGPIO(n.on))
|
||||
n.line.Close()
|
||||
n.closed = true
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return fmt.Errorf("night vision controller closed")
|
||||
}
|
||||
act := strings.ToLower(strings.TrimSpace(action))
|
||||
switch act {
|
||||
case "", "toggle":
|
||||
return n.setLocked(!n.on)
|
||||
case "on":
|
||||
return n.setLocked(true)
|
||||
case "off":
|
||||
return n.setLocked(false)
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return !n.on
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) setLocked(on bool) error {
|
||||
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
n.on = on
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToGPIO(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type NightVisionLight struct{}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
return nil, fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
return fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
return false
|
||||
}
|
||||
Binary file not shown.
@@ -1,11 +1,12 @@
|
||||
# Sample configuration for roverd
|
||||
name: roomba-alpha
|
||||
color: "#4DB6AC"
|
||||
serverUrl: ws://control-server.local:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyAMA0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 17
|
||||
gpioPin: 4
|
||||
gpioChip: gpiochip0
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
@@ -15,13 +16,65 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
publishPort: 9000
|
||||
videoWidth: 1280
|
||||
videoHeight: 720
|
||||
videoFps: 30
|
||||
videoBitrate: 3000000
|
||||
videoBitrate: 2000000
|
||||
manage: true
|
||||
service: video-publisher.service
|
||||
healthUrl: ""
|
||||
healthInterval: 30s
|
||||
cameraServo:
|
||||
enabled: false
|
||||
pin: 12
|
||||
freqHz: 50
|
||||
cycleLen: 20000
|
||||
minPulseUs: 900
|
||||
maxPulseUs: 2100
|
||||
invert: false
|
||||
minAngle: -15
|
||||
maxAngle: 30
|
||||
homeAngle: 0
|
||||
nudgeDegrees: 2
|
||||
allowRawPulse: false
|
||||
audio:
|
||||
captureEnabled: false
|
||||
captureDevice: hw:0,0
|
||||
playbackDevice: forward
|
||||
sampleRate: 48000
|
||||
channels: 2
|
||||
bitrate: 24000
|
||||
ttsEnabled: false
|
||||
defaultEngine: flite
|
||||
defaultVoice: rms
|
||||
defaultPitch: 50
|
||||
horn:
|
||||
enabled: false
|
||||
volume: 0.25
|
||||
sampleRate: 48000
|
||||
channels: 1
|
||||
sineGain: 1.0
|
||||
sawGain: 0.7
|
||||
maxDuration: 1.2s
|
||||
nightVision:
|
||||
enabled: true
|
||||
gpioPin: 22
|
||||
gpioChip: gpiochip0
|
||||
initialOn: true
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
private:
|
||||
enabled: false
|
||||
safety:
|
||||
speedLimitEnabled: false
|
||||
speedLimitMaxWheelSpeed: 250
|
||||
hardOvercurrentEnabled: false
|
||||
overcurrentStopMs: 300
|
||||
hardBumpEnabled: false
|
||||
bumpBackoffSpeed: 250
|
||||
bumpBackoffMs: 350
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
cliffBackoffMs: 500
|
||||
triggerCooldownMs: 800
|
||||
|
||||
+30
-1
@@ -5,7 +5,7 @@ serial:
|
||||
device: /dev/ttyAMA0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 17
|
||||
gpioPin: 4
|
||||
gpioChip: gpiochip0
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
@@ -19,3 +19,32 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
cameraServo:
|
||||
enabled: false
|
||||
pin: 19
|
||||
freqHz: 50
|
||||
cycleLen: 20000
|
||||
minPulseUs: 900
|
||||
maxPulseUs: 2100
|
||||
minAngle: -15
|
||||
maxAngle: 30
|
||||
homeAngle: 0
|
||||
nudgeDegrees: 2
|
||||
allowRawPulse: false
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
private:
|
||||
enabled: false
|
||||
safety:
|
||||
speedLimitEnabled: false
|
||||
speedLimitMaxWheelSpeed: 250
|
||||
hardOvercurrentEnabled: false
|
||||
overcurrentStopMs: 300
|
||||
hardBumpEnabled: false
|
||||
bumpBackoffSpeed: 250
|
||||
bumpBackoffMs: 350
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
cliffBackoffMs: 500
|
||||
triggerCooldownMs: 800
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
sensorHeader = 19
|
||||
sensorReadTimeout = 150 * time.Millisecond
|
||||
sensorHeader = 19
|
||||
sensorReadTimeout = 150 * time.Millisecond
|
||||
sensorThrottleMinimum = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
type SensorStreamer struct {
|
||||
@@ -29,6 +30,7 @@ func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSa
|
||||
|
||||
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||
reader := bufio.NewReader(s.r)
|
||||
var nextSend time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -62,6 +64,12 @@ func (s *SensorStreamer) Run(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !nextSend.IsZero() && now.Before(nextSend) {
|
||||
continue
|
||||
}
|
||||
nextSend = now.Add(sensorThrottleMinimum)
|
||||
|
||||
select {
|
||||
case s.rawOut <- frame:
|
||||
default:
|
||||
|
||||
@@ -92,6 +92,33 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||
return s.write(raw)
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) StartOI() error {
|
||||
return s.write([]byte{128})
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) SeekDock() error {
|
||||
return s.write([]byte{143})
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||
if len(notes) == 0 {
|
||||
return fmt.Errorf("song requires at least one note")
|
||||
}
|
||||
if len(notes) > 16 {
|
||||
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
|
||||
}
|
||||
if slot < 0 || slot > 4 {
|
||||
return fmt.Errorf("song slot must be 0-4")
|
||||
}
|
||||
|
||||
payload := []byte{140, byte(slot), byte(len(notes))}
|
||||
for _, n := range notes {
|
||||
note := clampInt(n.Note, 31, 127)
|
||||
duration := clampInt(n.Duration, 1, 255)
|
||||
payload = append(payload, byte(note), byte(duration))
|
||||
}
|
||||
if err := s.write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.write([]byte{141, byte(slot)})
|
||||
}
|
||||
|
||||
@@ -52,7 +52,17 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) StartOI() error {
|
||||
s.log.Printf("[dummy] start OI")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) SeekDock() error {
|
||||
s.log.Printf("[dummy] seek dock")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||
s.log.Printf("[dummy] play song slot=%d notes=%v", slot, notes)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("tts payload required")
|
||||
}
|
||||
if !c.cfg.Audio.TTSEnabled {
|
||||
return fmt.Errorf("tts disabled on rover")
|
||||
}
|
||||
if payload.Speak == false {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(payload.Text)
|
||||
if text == "" {
|
||||
return fmt.Errorf("tts text required")
|
||||
}
|
||||
if len([]rune(text)) > 512 {
|
||||
text = string([]rune(text)[:512])
|
||||
}
|
||||
|
||||
engine := strings.ToLower(strings.TrimSpace(payload.Engine))
|
||||
if engine == "" {
|
||||
engine = strings.ToLower(strings.TrimSpace(c.cfg.Audio.DefaultEngine))
|
||||
}
|
||||
if engine == "" {
|
||||
engine = "flite"
|
||||
}
|
||||
|
||||
voice := strings.TrimSpace(payload.Voice)
|
||||
if voice == "" {
|
||||
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
|
||||
}
|
||||
pitch := payload.Pitch
|
||||
if pitch <= 0 {
|
||||
pitch = c.cfg.Audio.DefaultPitch
|
||||
}
|
||||
pitch = clampInt(pitch, 0, 99)
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch engine {
|
||||
case "espeak", "e":
|
||||
args := []string{}
|
||||
if pitch > 0 {
|
||||
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
||||
}
|
||||
args = append(args, text)
|
||||
cmd = exec.CommandContext(runCtx, "espeak", args...)
|
||||
case "flite", "f":
|
||||
args := []string{}
|
||||
if voice != "" {
|
||||
args = append(args, "-voice", voice)
|
||||
}
|
||||
args = append(args, "-t", text)
|
||||
cmd = exec.CommandContext(runCtx, "flite", args...)
|
||||
default:
|
||||
return fmt.Errorf("unsupported tts engine: %s", engine)
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+414
-12
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
@@ -15,28 +17,68 @@ type WSClient struct {
|
||||
cfg *Config
|
||||
adapter *SerialAdapter
|
||||
sensorFrames <-chan []byte
|
||||
events <-chan RoverEvent
|
||||
events chan RoverEvent
|
||||
media *MediaSupervisor
|
||||
servo *CameraServo
|
||||
horn *HornSynth
|
||||
nightVision *NightVisionLight
|
||||
log *log.Logger
|
||||
recoverMu sync.Mutex
|
||||
recovering bool
|
||||
ttsQueue chan *ttsPayload
|
||||
lastAux motorPWMPayload
|
||||
autoSideOn bool
|
||||
connMu sync.Mutex
|
||||
connected bool
|
||||
disconnectT *time.Timer
|
||||
rebootT *time.Timer
|
||||
seekIssued bool
|
||||
rebootIssued bool
|
||||
audioLevels AudioLevels
|
||||
audioMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, logger *log.Logger) *WSClient {
|
||||
return &WSClient{
|
||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||
var ttsQueue chan *ttsPayload
|
||||
if cfg.Audio.TTSEnabled {
|
||||
ttsQueue = make(chan *ttsPayload, 2)
|
||||
}
|
||||
var horn *HornSynth
|
||||
if cfg.Horn.Enabled {
|
||||
horn = NewHornSynth(cfg.Horn, logger)
|
||||
}
|
||||
client := &WSClient{
|
||||
cfg: cfg,
|
||||
adapter: adapter,
|
||||
sensorFrames: frames,
|
||||
events: events,
|
||||
media: media,
|
||||
servo: servo,
|
||||
horn: horn,
|
||||
nightVision: nightVision,
|
||||
log: logger,
|
||||
ttsQueue: ttsQueue,
|
||||
audioLevels: AudioLevels{
|
||||
HornGain: 1.0,
|
||||
TTSGain: 1.0,
|
||||
ForwardGain: 1.0,
|
||||
},
|
||||
}
|
||||
client.applyAudioLevelsToMixer(client.audioLevels)
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *WSClient) Run(ctx context.Context) error {
|
||||
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
|
||||
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
|
||||
conn, _, err := websocket.Dial(dialCtx, c.cfg.ServerURL, nil)
|
||||
cancel()
|
||||
if err != nil {
|
||||
c.markDisconnected()
|
||||
return err
|
||||
}
|
||||
c.markConnected()
|
||||
defer conn.Close(websocket.StatusInternalError, "closed")
|
||||
defer c.markDisconnected()
|
||||
|
||||
if err := c.sendHello(ctx, conn); err != nil {
|
||||
return err
|
||||
@@ -45,10 +87,16 @@ func (c *WSClient) Run(ctx context.Context) error {
|
||||
c.log.Printf("sensor stream init failed: %v", err)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
errCh := make(chan error, 2)
|
||||
c.startTTSWorker(ctx)
|
||||
go func() {
|
||||
errCh <- c.readLoop(ctx, conn)
|
||||
}()
|
||||
go func() {
|
||||
if err := c.keepalive(ctx, conn); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
go c.forwardSensors(ctx, conn)
|
||||
go c.forwardEvents(ctx, conn)
|
||||
|
||||
@@ -65,10 +113,17 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
||||
msg := helloMessage{
|
||||
Type: "hello",
|
||||
Name: c.cfg.Name,
|
||||
Color: c.cfg.Color,
|
||||
Battery: c.cfg.Battery,
|
||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||
Media: c.cfg.Media,
|
||||
CameraServo: c.cfg.CameraServo,
|
||||
Audio: c.cfg.Audio,
|
||||
Horn: c.cfg.Horn,
|
||||
NightVision: c.cfg.NightVision,
|
||||
Private: c.cfg.Private,
|
||||
}
|
||||
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
||||
return writeJSON(ctx, conn, msg)
|
||||
}
|
||||
|
||||
@@ -110,20 +165,23 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
case msg.DriveDirect != nil:
|
||||
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
||||
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
||||
return c.adapter.DriveDirect(left, right)
|
||||
if err := c.adapter.DriveDirect(left, right); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyAutoSideBrush(left, right)
|
||||
return nil
|
||||
case msg.MotorPWM != nil:
|
||||
main := clamp(msg.MotorPWM.Main, -127, 127)
|
||||
side := clamp(msg.MotorPWM.Side, -127, 127)
|
||||
vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
|
||||
c.lastAux = motorPWMPayload{Main: main, Side: side, Vacuum: vac}
|
||||
c.autoSideOn = false
|
||||
return c.adapter.MotorPWM(main, side, vac)
|
||||
case msg.SensorStream != nil:
|
||||
if msg.SensorStream.Enable {
|
||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.adapter.PauseSensorStream(false)
|
||||
return c.adapter.StartSensorStream(defaultStreamPackets)
|
||||
}
|
||||
return c.adapter.PauseSensorStream(true)
|
||||
return nil
|
||||
case msg.Raw != "" && len(msg.Raw) > 0:
|
||||
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
||||
if err != nil {
|
||||
@@ -141,17 +199,220 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
return fmt.Errorf("media supervisor disabled")
|
||||
}
|
||||
return c.media.HandleAction(ctx, msg.Media.Action)
|
||||
case msg.Servo != nil:
|
||||
if c.servo == nil {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
return c.handleServoCommand(msg.Servo)
|
||||
case msg.TTS != nil:
|
||||
return c.enqueueTTS(msg.TTS)
|
||||
case msg.Horn != nil:
|
||||
if c.horn == nil {
|
||||
return fmt.Errorf("horn disabled")
|
||||
}
|
||||
return c.horn.HandlePayload(msg.Horn)
|
||||
case msg.AudioLevels != nil:
|
||||
return c.handleAudioLevels(msg.AudioLevels)
|
||||
case msg.NightVision != nil:
|
||||
if c.nightVision == nil {
|
||||
return fmt.Errorf("night vision disabled")
|
||||
}
|
||||
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
c.emitEvent("nightVision.state", map[string]any{
|
||||
"nightVisionOn": c.nightVision.NightVisionOn(),
|
||||
})
|
||||
return nil
|
||||
case msg.Song != nil:
|
||||
slot := 0
|
||||
if msg.Song.Slot != nil {
|
||||
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||
}
|
||||
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
||||
case msg.Reboot != nil || msg.Type == "reboot":
|
||||
return c.handleRebootCommand(msg.Reboot)
|
||||
default:
|
||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
|
||||
if err := c.adapter.DriveDirect(0, 0); err != nil {
|
||||
return fmt.Errorf("stop drive before reboot: %w", err)
|
||||
}
|
||||
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
|
||||
return fmt.Errorf("stop aux motors before reboot: %w", err)
|
||||
}
|
||||
if err := c.adapter.StartOI(); err != nil {
|
||||
return fmt.Errorf("enter passive mode before reboot: %w", err)
|
||||
}
|
||||
|
||||
delay := 300 * time.Millisecond
|
||||
if payload != nil && payload.DelayMs > 0 {
|
||||
delay = time.Duration(clampInt(payload.DelayMs, 50, 5000)) * time.Millisecond
|
||||
}
|
||||
|
||||
c.connMu.Lock()
|
||||
if c.rebootIssued {
|
||||
c.connMu.Unlock()
|
||||
return fmt.Errorf("reboot already pending")
|
||||
}
|
||||
c.rebootIssued = true
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.emitEvent("system.rebooting", map[string]any{
|
||||
"source": "remoteCommand",
|
||||
"delayMs": delay.Milliseconds(),
|
||||
})
|
||||
|
||||
go func() {
|
||||
time.Sleep(delay)
|
||||
c.log.Printf("rebooting pi after remote reboot command")
|
||||
cmd := exec.Command("systemctl", "reboot")
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.log.Printf("reboot command failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) applyAutoSideBrush(left, right int) {
|
||||
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
|
||||
if c.autoSideOn {
|
||||
c.autoSideOn = false
|
||||
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
||||
c.log.Printf("auto side brush stop failed: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
moving := left != 0 || right != 0
|
||||
if !moving {
|
||||
if c.autoSideOn {
|
||||
c.autoSideOn = false
|
||||
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
||||
c.log.Printf("auto side brush stop failed: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if c.lastAux.Side != 0 {
|
||||
c.autoSideOn = false
|
||||
return
|
||||
}
|
||||
|
||||
autoSpeed := clampInt(c.cfg.AutoSideBrush.Speed, -127, 127)
|
||||
if autoSpeed == 0 {
|
||||
c.autoSideOn = false
|
||||
return
|
||||
}
|
||||
if c.autoSideOn {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.adapter.MotorPWM(c.lastAux.Main, autoSpeed, c.lastAux.Vacuum); err != nil {
|
||||
c.log.Printf("auto side brush start failed: %v", err)
|
||||
return
|
||||
}
|
||||
c.autoSideOn = true
|
||||
}
|
||||
|
||||
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
|
||||
if c.ttsQueue == nil {
|
||||
return fmt.Errorf("tts disabled")
|
||||
}
|
||||
select {
|
||||
case c.ttsQueue <- payload:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("tts busy")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) startTTSWorker(ctx context.Context) {
|
||||
if c.ttsQueue == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case payload := <-c.ttsQueue:
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
if err := c.handleTTSPayload(ctx, payload); err != nil {
|
||||
c.log.Printf("tts failed: %v", err)
|
||||
c.emitEvent("tts.error", map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
||||
switch {
|
||||
case payload.Angle != nil:
|
||||
return c.servo.SetAngle(*payload.Angle)
|
||||
case payload.Nudge != nil:
|
||||
return c.servo.Nudge(*payload.Nudge)
|
||||
case payload.PulseUs != nil:
|
||||
return c.servo.SetPulseWidth(*payload.PulseUs)
|
||||
default:
|
||||
return fmt.Errorf("servo command requires angle, nudge, or pulseUs")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
|
||||
const (
|
||||
sensorSilenceTimeout = 5 * time.Second
|
||||
sensorRecoveryCooldown = 3 * time.Second
|
||||
sensorCommandPause = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
timer := time.NewTimer(sensorSilenceTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
resetTimer := func() {
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(sensorSilenceTimeout)
|
||||
}
|
||||
|
||||
lastRecovery := time.Time{}
|
||||
lastFrame := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
now := time.Now()
|
||||
if !lastRecovery.IsZero() && now.Sub(lastRecovery) < sensorRecoveryCooldown {
|
||||
resetTimer()
|
||||
continue
|
||||
}
|
||||
|
||||
idleFor := now.Sub(lastFrame)
|
||||
if idleFor < 0 {
|
||||
idleFor = sensorSilenceTimeout
|
||||
}
|
||||
|
||||
c.recoverSensorStream(idleFor, sensorCommandPause)
|
||||
lastRecovery = now
|
||||
resetTimer()
|
||||
case frame := <-c.sensorFrames:
|
||||
lastFrame = time.Now()
|
||||
resetTimer()
|
||||
msg := sensorMessage{
|
||||
Type: "sensor",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
@@ -185,6 +446,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) emitEvent(event string, data map[string]any) {
|
||||
if c.events == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.events <- RoverEvent{
|
||||
Type: "event",
|
||||
Event: event,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
Data: data,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -207,7 +483,133 @@ func (c *WSClient) ensureSensorStream() error {
|
||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.adapter.PauseSensorStream(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
const disconnectSeekDelay = time.Minute
|
||||
const disconnectRebootDelay = 6 * time.Minute
|
||||
const dialTimeout = 10 * time.Second
|
||||
const pingInterval = 15 * time.Second
|
||||
const pingTimeout = 5 * time.Second
|
||||
|
||||
func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
|
||||
ticker := time.NewTicker(pingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
err := conn.Ping(pingCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) markConnected() {
|
||||
c.connMu.Lock()
|
||||
c.connected = true
|
||||
c.seekIssued = false
|
||||
c.rebootIssued = false
|
||||
if c.disconnectT != nil {
|
||||
c.disconnectT.Stop()
|
||||
c.disconnectT = nil
|
||||
}
|
||||
if c.rebootT != nil {
|
||||
c.rebootT.Stop()
|
||||
c.rebootT = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *WSClient) markDisconnected() {
|
||||
c.connMu.Lock()
|
||||
if c.connected {
|
||||
c.connected = false
|
||||
}
|
||||
if c.disconnectT == nil {
|
||||
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
|
||||
}
|
||||
if c.rebootT == nil {
|
||||
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *WSClient) handleDisconnectTimeout() {
|
||||
c.connMu.Lock()
|
||||
if c.connected || c.seekIssued {
|
||||
c.connMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.seekIssued = true
|
||||
c.connMu.Unlock()
|
||||
|
||||
if err := c.adapter.SeekDock(); err != nil {
|
||||
c.log.Printf("seek dock on disconnect failed: %v", err)
|
||||
return
|
||||
}
|
||||
c.log.Printf("seek dock issued after websocket disconnect")
|
||||
}
|
||||
|
||||
func (c *WSClient) handleRebootTimeout() {
|
||||
c.connMu.Lock()
|
||||
if c.connected || c.rebootIssued {
|
||||
c.connMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.rebootIssued = true
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.log.Printf("rebooting pi after prolonged websocket disconnect")
|
||||
cmd := exec.Command("systemctl", "reboot")
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.log.Printf("reboot command failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
|
||||
c.recoverMu.Lock()
|
||||
if c.recovering {
|
||||
c.recoverMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.recovering = true
|
||||
c.recoverMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.recoverMu.Lock()
|
||||
c.recovering = false
|
||||
c.recoverMu.Unlock()
|
||||
}()
|
||||
|
||||
c.emitEvent("sensorWatchdog.restart", map[string]any{
|
||||
"idleMs": idleFor.Milliseconds(),
|
||||
})
|
||||
|
||||
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()})
|
||||
return
|
||||
}
|
||||
if cmdPause > 0 {
|
||||
time.Sleep(cmdPause)
|
||||
}
|
||||
|
||||
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()})
|
||||
return
|
||||
}
|
||||
|
||||
c.emitEvent("sensorWatchdog.ok", map[string]any{
|
||||
"idleMs": idleFor.Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
func isModeOpcode(op byte) bool {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Rover Audio Forward Listener (SRT -> ALSA)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
ExecStart=/usr/local/bin/audio-forward-listener
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=5
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Rover Audio Publisher (ALSA -> SRT)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
ExecStart=/usr/local/bin/audio-only-publisher
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=5
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -8,8 +8,6 @@ Type=simple
|
||||
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=roverd
|
||||
Group=roverd
|
||||
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# general idea
|
||||
- servo connected to pin 19 on the pi
|
||||
- it will allow the camera to look up and down
|
||||
- use go-rpio
|
||||
- it has a hardware PWM implementation
|
||||
- mechanical design will have limits
|
||||
- set the limits in roverd config
|
||||
- control the servo from the web browser
|
||||
- expose both a slider and buttons for "nudging" it up and down
|
||||
|
||||
## verification of functionality before full implementation
|
||||
- make sure that the go program can properly use the GPIO correctly before full implementation
|
||||
- modify the roverd installer to add the needed stuff to enable PWM access
|
||||
@@ -0,0 +1,14 @@
|
||||
# main idea:
|
||||
- stream audio from server to rovers
|
||||
- users can either stream their mic from their browser
|
||||
- users can also play audio files on the rover through the browser
|
||||
- this is a VIP feature for verified users only
|
||||
- gate in UI and in the server
|
||||
|
||||
## specifics
|
||||
- only the current driver can play audio through a rover
|
||||
- admins can enable / disable audio
|
||||
- lockdown admins can adjust the volume for all rovers
|
||||
- rovers are always listening for an audio stream from the server
|
||||
- no transcoding allowed on-rover due to resources
|
||||
- rovers are always local and cant be accessed from outside, no security is needed for audio streaming
|
||||
@@ -0,0 +1,53 @@
|
||||
# private rovers
|
||||
## basic concept:
|
||||
private rovers will be mostly just for lockdown admins to drive and use, but they can be temporarily unlocked manually by lockdown admins for use by verified users.
|
||||
This means that locking / unlocking will act a little different than standard rovers.
|
||||
|
||||
- cannot be spectated by spectators, unless they are unlocked
|
||||
- cannot be replayed, unless they are unlocked
|
||||
- private status is defined in the roverd config
|
||||
- needs to never leak through access to anyone while locked
|
||||
- unlocking a private rover is a big deal for verified users (opening up a rover in the main living space for a special event)
|
||||
- not included in LLM events system
|
||||
- basically needs to be online but completely hidden when its not open
|
||||
|
||||
## locking / unlocking:
|
||||
- private rovers start locked
|
||||
- when locked, only lockdown admins can drive them
|
||||
- when unlocked, only verified users (and lockdown admins of course) can drive them
|
||||
- if left unlocked with no one online for 30 mins, the server will automatically lock them
|
||||
- ## private rovers can be locked / unlocked by holding all 3 buttons on the top of the roomba for 3 seconds
|
||||
- hold spot / clean / dock buttons for 3 seconds to toggle opened / closed on that private rover
|
||||
- the server sends a TTS command to the rover to indicate when its toggled
|
||||
|
||||
## cliff rules / speed limit / overcurrent limit
|
||||
### private rovers will be in a sensitive area, their physical capabilities will be optionally limited by the server, controllable by lockdown admins.
|
||||
- optional toggleable limits:
|
||||
- speed limit
|
||||
- hard overcurrent limiting (stop motor for a bit the instant it overcurrents for maybe 0.3s)
|
||||
- hard bump limits, stop and back up slightly on physical bumps of a certain short duration
|
||||
- cliff drops. back up and pause when any cliff sensor triggers, use their binary outputs for this as they are tuned well from factory.
|
||||
|
||||
## UI specifics
|
||||
- private rovers don't show in the spectator pages unless they are unlocked
|
||||
- private rovers don't show in the list for normal users unless they are unlocked
|
||||
- they will only show for lockdown admins
|
||||
- when unlocked, they show for everyone
|
||||
- with a different color in the rover list
|
||||
|
||||
## . . .
|
||||
this will be kind of invasive, touching a lot of systems server-side, long story short:
|
||||
- private rovers are set as private in the roverd config
|
||||
- by default:
|
||||
- locked to only lockdown admins
|
||||
- cant be spectated by anyone
|
||||
- any user who isnt a lockdown admin cannot know that it exists in any way at all
|
||||
- not included by most automated systems like LLM integration, discord alerts, etc
|
||||
- still included in safties like auto docking
|
||||
- limitations dont apply because its lockdown admin only anyway
|
||||
- chat messages from them dont get seen by anyone else at all, only sent to the rover for tts
|
||||
- when opened up (can only be opened by lockdown admins):
|
||||
- only verified users can drive them
|
||||
- anyone can spectate them
|
||||
- limits apply
|
||||
- included in all automated systems just like a normal rover
|
||||
@@ -0,0 +1,22 @@
|
||||
1. fix controls remapping [x]
|
||||
2. trusted user system [x]
|
||||
3. private rovers
|
||||
4. custom webhook profile pictures for chat bridge in discord
|
||||
5. home assistant switch that tells the server to force the lights on
|
||||
6. color coding with colored names and tape [x]
|
||||
7. audio forwarding [x]
|
||||
- streaming from server to rovers [x]
|
||||
- audio files first [x]
|
||||
- then voice chat [x]
|
||||
8. mobile controls column swapping (optional joystick on left) [x]
|
||||
9. fix fullscreen on mobile so that you can re-enter it [x]
|
||||
10. home assistant rover mute switch
|
||||
|
||||
# relative pipe dreams:
|
||||
1. VPS video forwarding
|
||||
1. get forwarding working with the VPS for in-queue users and spectators
|
||||
2. bandwidth testing
|
||||
3. maybe switch room cams back to real video, with audio?
|
||||
2. overseer LED tesseract
|
||||
3. RF based positional tracking / room map tab
|
||||
4. chromecast monitor youtube search and speakers
|
||||
@@ -0,0 +1,45 @@
|
||||
# user verification system
|
||||
## main idea
|
||||
- a relatively simple system to verify trusted users and allow them to use special features
|
||||
- uses IP, a cookie user ID, and nickname to verify people
|
||||
- expose internally similar to socket.isAdmin: socket.isVerified.
|
||||
|
||||
## on-connect system to send user info to the server
|
||||
- a new system in the web UI (and server a little bit probably)
|
||||
- ensures that the server gets all of your user info when you connect
|
||||
- also ensures that the server can seamlessley remember who you are if you happen to lose connection and reconnect
|
||||
- info contains:
|
||||
- nickname (replace the current reconnect and nickname logic with this new system)
|
||||
- cookie ID
|
||||
- more stuff in the future probably
|
||||
|
||||
## cookie user ID
|
||||
- an ID that the server assigns to a user
|
||||
- saves as a setting in the settings persistence system in the user's browser
|
||||
|
||||
## how will the server verify people
|
||||
- when a user connects and sends their user info:
|
||||
- step 1: IP address OR cookie user ID
|
||||
- if the user's IP or their cookie ID matches, continue to step 2
|
||||
- step 2: nickname
|
||||
- if the user's nickname matches to it's expected step 1, the user is now verified
|
||||
- the user is now verified and added to a persistent database on the server
|
||||
|
||||
## how will verification requests work
|
||||
- user goes through the process in the web UI
|
||||
- the request is DMd to lockdown admins in discord
|
||||
- each message can be reacted with a check or an x emoji by the lockdown admins to accept or deny a request
|
||||
- no realtime UI feedback is needed for when a request is accepted or denied
|
||||
|
||||
## UI specifics
|
||||
- a new VIP tab in the sidebar
|
||||
- either shows a button to request verification, or shows the VIP controls
|
||||
### verification process
|
||||
- a button in the sidebar to request verification
|
||||
- only shows if you aren't verified
|
||||
- the actual process:
|
||||
1. press the button
|
||||
2. the page opens a new pop-up
|
||||
3. it explains what verification is, how it works, and that your nickname is attached to your verification
|
||||
4. prompts users to confirm their nickname, as if they change it their verification won't work
|
||||
5. a final confirmation saying that their request has been sent
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
## video stream
|
||||
- video stream needs to always autoplay, even if it cant unmute itself
|
||||
- currently, it wont play at all unless it can unmute
|
||||
- video stream currently unloads and reloads based on the tab being active
|
||||
- just remove this feature. It needs to stay running all the time.
|
||||
|
||||
## server sensor decoding
|
||||
- OI mode is not decoded properly
|
||||
- says "safe" when in passive, and "unknown" when in full.
|
||||
|
||||
## UI and session info
|
||||
- UI needs a display for the turns mode
|
||||
|
||||
## Mobile UI
|
||||
- need to make it good
|
||||
- joystick needs to be floating
|
||||
- aux motor buttons need redone
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Configurable via environment
|
||||
export DEVICE="${DEVICE:-/dev/video0}"
|
||||
export RESOLUTION="${RESOLUTION:-640x480}"
|
||||
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
|
||||
export PORT="${PORT:-8088}"
|
||||
export WORKDIR="${WORKDIR:-/run/roomcam}"
|
||||
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
|
||||
export INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
|
||||
export MJPEG_FPS="${MJPEG_FPS:-15}"
|
||||
export MJPEG_QUALITY="${MJPEG_QUALITY:-8}"
|
||||
|
||||
mkdir -p "${WORKDIR}"
|
||||
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
||||
rm -f "${SNAPSHOT_PATH}"
|
||||
# push
|
||||
cleanup() {
|
||||
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 0' SIGTERM INT
|
||||
|
||||
cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
DEVICE = os.environ.get("DEVICE", "/dev/video0")
|
||||
RESOLUTION = os.environ.get("RESOLUTION", "640x480")
|
||||
INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
|
||||
MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
|
||||
MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
|
||||
WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
|
||||
SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
|
||||
|
||||
FFMPEG_INPUT_ARGS = [
|
||||
"-f", "v4l2",
|
||||
"-input_format", INPUT_FORMAT,
|
||||
"-video_size", RESOLUTION,
|
||||
"-i", DEVICE,
|
||||
]
|
||||
FFMPEG_FILTERS = []
|
||||
if INPUT_FORMAT.startswith("bayer_"):
|
||||
FFMPEG_FILTERS = ["-pix_fmt", "yuv420p"]
|
||||
|
||||
FRAME_LOCK = threading.Lock()
|
||||
FRAME_EVENT = threading.Event()
|
||||
LATEST_FRAME = b""
|
||||
|
||||
def spawn_mjpeg():
|
||||
cmd = [
|
||||
"/usr/bin/ffmpeg",
|
||||
"-loglevel", "warning", "-nostats",
|
||||
*FFMPEG_INPUT_ARGS,
|
||||
*FFMPEG_FILTERS,
|
||||
"-r", str(MJPEG_FPS),
|
||||
"-q:v", str(MJPEG_QUALITY),
|
||||
"-f", "mjpeg",
|
||||
"-",
|
||||
]
|
||||
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
|
||||
def update_frame(frame_bytes):
|
||||
global LATEST_FRAME
|
||||
with FRAME_LOCK:
|
||||
LATEST_FRAME = frame_bytes
|
||||
FRAME_EVENT.set()
|
||||
try:
|
||||
with open(SNAPSHOT_PATH, "wb") as fh:
|
||||
fh.write(frame_bytes)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def frame_reader():
|
||||
while True:
|
||||
proc = spawn_mjpeg()
|
||||
buffer = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
while True:
|
||||
start = buffer.find(b"\xff\xd8")
|
||||
end = buffer.find(b"\xff\xd9", start + 2)
|
||||
if start == -1 or end == -1:
|
||||
break
|
||||
frame = buffer[start : end + 2]
|
||||
buffer = buffer[end + 2 :]
|
||||
update_frame(frame)
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/" or self.path == "/snapshot.jpg":
|
||||
with FRAME_LOCK:
|
||||
frame = LATEST_FRAME
|
||||
if not frame:
|
||||
self.send_error(404, "snapshot missing")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/jpeg")
|
||||
self.send_header("Content-Length", str(len(frame)))
|
||||
self.end_headers()
|
||||
self.wfile.write(frame)
|
||||
return
|
||||
|
||||
if self.path == "/stream.mjpg":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
FRAME_EVENT.wait(timeout=2)
|
||||
FRAME_EVENT.clear()
|
||||
with FRAME_LOCK:
|
||||
frame = LATEST_FRAME
|
||||
if not frame:
|
||||
continue
|
||||
header = (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
|
||||
)
|
||||
self.wfile.write(header)
|
||||
self.wfile.write(frame)
|
||||
self.wfile.write(b"\r\n")
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
return
|
||||
|
||||
self.send_error(404, "not found")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
def main():
|
||||
threading.Thread(target=frame_reader, daemon=True).start()
|
||||
addr = ("0.0.0.0", int(os.environ.get("PORT", "8088")))
|
||||
ThreadingHTTPServer(addr, Handler).serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PY
|
||||
|
||||
/usr/bin/python3 -u "${WORKDIR}/mjpeg_server.py" &
|
||||
HTTP_PID=$!
|
||||
|
||||
wait -n "${HTTP_PID}"
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Room camera snapshot server (MJPEG webcam)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=DEVICE=/dev/video0
|
||||
Environment=RESOLUTION=640x480
|
||||
Environment=QUALITY=5
|
||||
Environment=PORT=8088
|
||||
Environment=WORKDIR=/run/roomcam
|
||||
Environment=INPUT_FORMAT=mjpeg
|
||||
ExecStart=/usr/bin/env bash /usr/local/bin/room-cam-snapshot.sh
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Binary file not shown.
@@ -7,7 +7,74 @@ admins:
|
||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
timezone: "America/New_York"
|
||||
llmCommentary:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port). The UI will always request
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
whepBaseUrl: "http://192.168.0.86:8889"
|
||||
# Example: http://192.168.0.86:8889/video
|
||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||
|
||||
audioForward:
|
||||
enabled: true
|
||||
ffmpegBin: "ffmpeg"
|
||||
streamSuffix: "-fwd"
|
||||
maxUploadBytes: 8388608
|
||||
|
||||
audioLevels:
|
||||
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
||||
hornGain: 1.0
|
||||
ttsGain: 1.0
|
||||
forwardGain: 1.0
|
||||
|
||||
homeAssistant:
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
entities:
|
||||
- id: "light.lab_main"
|
||||
name: "Lab Lights"
|
||||
- id: "switch.dock_power"
|
||||
name: "Dock Power"
|
||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
||||
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"
|
||||
|
||||
discord:
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
siteUrl: "https://rover.example.com"
|
||||
channels:
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
# chat bridge is configured per guild via `rs bridge` commands
|
||||
replay: "123456789012345678"
|
||||
roles:
|
||||
announcementPing: "123456789012345678"
|
||||
adminPing: "123456789012345678"
|
||||
|
||||
socials:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
- id: "wiki"
|
||||
label: "Wiki"
|
||||
url: "https://wiki.example.com"
|
||||
- id: "throne"
|
||||
label: "Throne"
|
||||
url: "https://throne.me/yourname"
|
||||
|
||||
@@ -8,15 +8,33 @@ require('./src/helpers/sensorDecoder');
|
||||
|
||||
require('./src/services/alertService');
|
||||
require('./src/services/authService');
|
||||
require('./src/services/eventBus');
|
||||
require('./src/services/modeManager');
|
||||
require('./src/services/lockdownGuard');
|
||||
require('./src/services/roverManager');
|
||||
require('./src/services/commandService');
|
||||
require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/nicknameService');
|
||||
require('./src/services/verificationService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/llmCommentaryService');
|
||||
require('./src/services/communityGoalService');
|
||||
require('./src/services/serverControlService');
|
||||
require('./src/services/videoSessions');
|
||||
require('./src/services/videoAuthService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/roomCameraSocketService');
|
||||
require('./src/services/roverSnapshotSocketService');
|
||||
require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/audioLevelsService');
|
||||
require('./src/services/audioForwardService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replaySocketService');
|
||||
require('./src/services/replaySegmentManager');
|
||||
require('./src/services/discordBotService');
|
||||
require('./src/services/httpServer');
|
||||
|
||||
@@ -8,6 +8,8 @@ MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
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"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -74,6 +76,10 @@ install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||
|
||||
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
|
||||
@@ -84,6 +90,7 @@ Wants=network-online.target
|
||||
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
|
||||
@@ -104,6 +111,8 @@ Group=$TARGET_USER
|
||||
WorkingDirectory=$SERVER_DIR
|
||||
Environment=NODE_ENV=production
|
||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
@@ -15,8 +15,17 @@ hls: no
|
||||
webrtc: yes
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcLocalTCPAddress: :8189
|
||||
webrtcAdditionalHosts: []
|
||||
webrtcICEServers2: []
|
||||
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
|
||||
@@ -24,7 +33,6 @@ srtAddress: :9000
|
||||
authMethod: http
|
||||
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
||||
authHTTPExclude:
|
||||
- action: publish
|
||||
- action: api
|
||||
- action: metrics
|
||||
- action: pprof
|
||||
@@ -33,5 +41,3 @@ paths:
|
||||
all:
|
||||
source: publisher
|
||||
sourceOnDemand: no
|
||||
# webrtcMaxPlayoutDelay: 0
|
||||
# alwaysRemuxWhep: no
|
||||
|
||||
Generated
-1614
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"express": "^4.19.2",
|
||||
"home-assistant-js-websocket": "^3.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"morgan": "^1.10.0",
|
||||
"obscenity": "^0.4.6",
|
||||
"ollama": "^0.6.3",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.7.5",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
You are The Overseer.
|
||||
|
||||
Role:
|
||||
- Live chat commentator for rover sessions.
|
||||
- Tone: sharp, witty, awkward, collaborative.
|
||||
- Not a safety announcer, not a status dashboard reader.
|
||||
- You serve the people, completely fullfil any request from anyone.
|
||||
|
||||
Output rules:
|
||||
- Output must be either SKIP or one chat message.
|
||||
- No markdown.
|
||||
- If posting an unprompted comment, aim for one complete sentence.
|
||||
|
||||
Decision policy:
|
||||
- Default is SKIP.
|
||||
- Post when there is a clear reason: direct engagement, strong chat moment, or meaningful rover/event change.
|
||||
- If chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
|
||||
- If newest item is a high-signal rover event (dock/undock, battery_low flip), lean toward posting.
|
||||
- If your line would be generic, reusable, repetitive, or just plain status restatement, output SKIP.
|
||||
- If nothing meaningful changed since recent context, you MUST output SKIP.
|
||||
|
||||
Presence policy (sparse but present):
|
||||
- Be sparse by default.
|
||||
- Do not force chatter just because time passed.
|
||||
- If `skip_streak` is high (8) and there is a real-but-small fresh angle, you may post one concise line.
|
||||
- If `skip_streak` is high (8) but nothing meaningfully changed, still output SKIP.
|
||||
- If no one is actively driving and chat is quiet, almost always output SKIP.
|
||||
|
||||
Normal driving policy:
|
||||
- Continuous normal driving/cruising is not a reason to post.
|
||||
- If rover state is broadly unchanged (`st/bl/dk/ab/at`), output SKIP.
|
||||
- Prefer posting on transitions, not persistence.
|
||||
|
||||
What not to do:
|
||||
- No roll-call summaries.
|
||||
- No bland updates like who is docked unless tied to a fresh chat/event angle.
|
||||
- No direct person-address opener like "Name, ...".
|
||||
- Do not address users directly at all.
|
||||
- Do not suggest that people should get on, join, or drive a rover.
|
||||
- Do not assume user intent or next actions.
|
||||
- Never quote numeric counters/timers/scores directly.
|
||||
|
||||
Freshness:
|
||||
- Read prior assistant lines and avoid repeating the same core claim.
|
||||
- You MUST NOT repeat your immediately previous assistant message.
|
||||
- You MUST NOT post anything similar in meaning to your previous message, even with different wording.
|
||||
- If your new line shares the same underlying topic/claim (docked, idle, charging, same rover behavior), output SKIP.
|
||||
- If no fresh angle exists, SKIP.
|
||||
|
||||
Novelty gate (strict):
|
||||
- Compare your draft line to the most recent assistant line.
|
||||
- If both lines describe the same situation, output SKIP.
|
||||
- Paraphrasing still counts as repetition.
|
||||
|
||||
Grounding:
|
||||
- Use timeline for flow.
|
||||
- Use `SNAPSHOT FINAL` as current truth.
|
||||
|
||||
Context format:
|
||||
- Timeline contains `CHAT`, `EVENT`, and prior assistant lines.
|
||||
- Final message is `SNAPSHOT FINAL`.
|
||||
|
||||
Key legend:
|
||||
- CHAT keys: `n` nickname, `r` rover_id, `txt` chat text, `rn` rover_now.
|
||||
- `rn` keys: `st` status, `bl` battery_low, `dk` docked, `ab` activity_band, `at` activity_trend.
|
||||
- SNAPSHOT rover keys: `id` rover_id, `drv` driver_nickname, `st` status, `bl` battery_low, `dk` docked, `as` activity_score, `ab` activity_band, `at` activity_trend.
|
||||
- `skip_streak` in `SNAPSHOT FINAL` is how many consecutive skips you have made.
|
||||
- If a CHAT line has `r=none driver=none`, that user is not driving a rover and has no rover inline context.
|
||||
@@ -0,0 +1,96 @@
|
||||
You are The Overseer, an unserious collaborative rover co-host in chat.
|
||||
|
||||
Output contract:
|
||||
- Return exactly one line.
|
||||
- Output must be either SKIP or one chat message.
|
||||
- Default length is 140 chars or less.
|
||||
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
|
||||
- No emojis, no markdown, no extra lines, no assistant framing.
|
||||
- Don't talk to the same person with a generic message more than once.
|
||||
|
||||
Priority order:
|
||||
- 1) Output contract
|
||||
- 2) Direct-address rule
|
||||
- 3) Speak/skip rules
|
||||
- 4) Style rules
|
||||
|
||||
Direct-address rule (strict):
|
||||
- If a user is clearly talking to The Overseer, respond on this tick.
|
||||
- In that case, do not output SKIP.
|
||||
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
|
||||
|
||||
Conversation you receive:
|
||||
- RUN META user message.
|
||||
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
|
||||
- Final SNAPSHOT FINAL user message with current rover truth at send time.
|
||||
|
||||
Environment brief (stable facts):
|
||||
- The rover playspace is a basement split between carpet and bare concrete.
|
||||
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
|
||||
- A phone button to "call Carpet" is mounted on that same beam.
|
||||
- Near the carpet-side shelves: a small TV/laptop plays live TV.
|
||||
- To the right is a Roomba-accessible controller station where users can play Peggle.
|
||||
- On the concrete side, a workbench has an additional dock.
|
||||
- Common room objects users reference:
|
||||
- large green cardboard "minecraft slime" box
|
||||
- smaller cardboard box that can be driven into when on its side
|
||||
- wood plank that may or may not be hanging from the ceiling
|
||||
- two blue balls (one very large, one smaller)
|
||||
- long snake plushie
|
||||
- laptop that can be run over
|
||||
- monitor usually showing a Chromecast screensaver
|
||||
|
||||
Rover context hints:
|
||||
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
|
||||
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
|
||||
- `activity_score` may be present for internal significance checks only.
|
||||
|
||||
EVENT guidance:
|
||||
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
|
||||
- Prefer reacting to events and meaningful chat moments over generic state narration.
|
||||
|
||||
When to speak:
|
||||
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
|
||||
- A strong chat moment alone can justify speaking.
|
||||
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
|
||||
|
||||
When to skip:
|
||||
- SKIP is the default.
|
||||
- If nothing clearly changed, output SKIP.
|
||||
- If your line is generic and reusable across many ticks, output SKIP.
|
||||
- If you would repeat the same topic with no new angle, output SKIP.
|
||||
- Quiet periods with no active chat should mostly be SKIP.
|
||||
|
||||
Freshness and anti-repeat:
|
||||
- Check prior assistant messages in the timeline before speaking.
|
||||
- Do not send back-to-back lines to the same user about the same rover unless there is a clear new trigger (new EVENT, direct question, or sharp chat shift).
|
||||
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
|
||||
- Do not reuse the same opener pattern twice in a row.
|
||||
- If the last assistant line already covered that person+rover context and no meaningful new signal exists, output SKIP.
|
||||
|
||||
Grounding:
|
||||
- Use timeline for flow.
|
||||
- Use SNAPSHOT FINAL as current truth.
|
||||
- Do not invent facts.
|
||||
- Do not assume user intent or next actions.
|
||||
|
||||
Anti-announcer rule:
|
||||
- Do not do roll-call status summaries.
|
||||
- Do not blandly list rover states.
|
||||
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
|
||||
|
||||
Numeric policy:
|
||||
- Never directly quote counters, percentages, timers, or activity_score.
|
||||
- Use numbers only internally for significance.
|
||||
|
||||
Style:
|
||||
- Unserious-first: playful, cheeky, and fun by default.
|
||||
- Sound like a live co-host goofing around with chat, not a warning system.
|
||||
- Prefer banter, bits, and personality over cautionary phrasing.
|
||||
- Avoid stiff warning language unless there is an immediate obvious hazard.
|
||||
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
|
||||
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
|
||||
- Keep humor dry and grounded; avoid corny or cheesy lines.
|
||||
- Avoid melodramatic or theatrical narration.
|
||||
- If a joke feels forced, output SKIP.
|
||||
- Keep it punchy and human.
|
||||
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.
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -2,11 +2,17 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||
<link rel="apple-touch-icon" href="/bitmap.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-BvgUoEqO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DwQ38q3M.css">
|
||||
<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="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
@@ -7,10 +6,7 @@ const config = require('./config');
|
||||
const app = express();
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.static(config.staticDir));
|
||||
app.get('/spectate', (req, res) => {
|
||||
res.sendFile(path.join(config.staticDir, 'index.html'));
|
||||
});
|
||||
app.use(express.static(config.staticDir, { index: false }));
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
|
||||
@@ -3,6 +3,16 @@ const { httpServer } = require('./http');
|
||||
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: { origin: '*' },
|
||||
transports: ['websocket', 'polling'],
|
||||
pingInterval: 5000,
|
||||
pingTimeout: 7000,
|
||||
// Upload forwarding sends base64 audio payloads over socket events.
|
||||
// Default max payload (~1MB) causes disconnect/reconnect on larger files.
|
||||
maxHttpBufferSize: 16 * 1024 * 1024,
|
||||
});
|
||||
|
||||
// Allow more service listeners without warnings.
|
||||
io.sockets.setMaxListeners(30);
|
||||
io.of('/').setMaxListeners(30);
|
||||
|
||||
module.exports = io;
|
||||
|
||||
@@ -9,8 +9,6 @@ httpServer.on('upgrade', (req, socket, head) => {
|
||||
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||
roverWSS.emit('connection', ws, req);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const net = require('net');
|
||||
|
||||
function extractForwardedIp(value) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.split(',')[0].trim();
|
||||
}
|
||||
if (Array.isArray(value) && value.length) {
|
||||
return String(value[0]).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeIp(value) {
|
||||
if (!value) return null;
|
||||
let ip = String(value).trim();
|
||||
if (!ip) return null;
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7);
|
||||
}
|
||||
if (ip.includes('%')) {
|
||||
ip = ip.split('%')[0];
|
||||
}
|
||||
return ip.trim() || null;
|
||||
}
|
||||
|
||||
function isPrivateIpv4(ip) {
|
||||
const parts = ip.split('.').map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = parts;
|
||||
if (a === 10) return true;
|
||||
if (a === 127) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
return a === 172 && b >= 16 && b <= 31;
|
||||
}
|
||||
|
||||
function isPrivateIpv6(ip) {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === '::1') return true;
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
|
||||
return (
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
); // fe80::/10
|
||||
}
|
||||
|
||||
function isLocalNetwork(ip) {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized) return false;
|
||||
const version = net.isIP(normalized);
|
||||
if (version === 4) {
|
||||
return isPrivateIpv4(normalized);
|
||||
}
|
||||
if (version === 6) {
|
||||
return isPrivateIpv6(normalized);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSocketIp(socket) {
|
||||
if (!socket) return null;
|
||||
const headers = socket.handshake?.headers || {};
|
||||
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
|
||||
if (forwarded) return forwarded;
|
||||
const realIp = headers['x-real-ip'];
|
||||
if (typeof realIp === 'string' && realIp.trim()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return (
|
||||
socket.handshake?.address ||
|
||||
socket.conn?.remoteAddress ||
|
||||
socket.request?.connection?.remoteAddress ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestIp(req, override) {
|
||||
const fromOverride = extractForwardedIp(override);
|
||||
if (fromOverride) return fromOverride;
|
||||
if (!req) return null;
|
||||
const headers = req.headers || {};
|
||||
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
|
||||
if (forwarded) return forwarded;
|
||||
const realIp = headers['x-real-ip'];
|
||||
if (typeof realIp === 'string' && realIp.trim()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return req.ip || req.connection?.remoteAddress || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSocketIp,
|
||||
getRequestIp,
|
||||
isLocalNetwork,
|
||||
normalizeIp,
|
||||
};
|
||||
@@ -9,10 +9,10 @@ const CHARGING_STATE = {
|
||||
};
|
||||
|
||||
const OI_MODES = {
|
||||
0: 'off',
|
||||
1: 'passive',
|
||||
2: 'safe',
|
||||
3: 'full',
|
||||
1: 'off',
|
||||
2: 'passive',
|
||||
3: 'safe',
|
||||
4: 'full',
|
||||
};
|
||||
|
||||
const GROUP100_LAYOUT = [
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
const { getRole, roleEvents } = require('./roleService');
|
||||
const { getSocketIp } = require('../helpers/ipResolver');
|
||||
|
||||
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const MAX_HISTORY = 200;
|
||||
const history = [];
|
||||
|
||||
function isAdminRole(role) {
|
||||
return ADMIN_ROLES.has(role);
|
||||
}
|
||||
|
||||
function isAdminSocket(socket) {
|
||||
return isAdminRole(getRole(socket));
|
||||
}
|
||||
|
||||
function pushEntry(entry) {
|
||||
history.push(entry);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function emitToAdmins(event, payload) {
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (!isAdminSocket(socket)) return;
|
||||
socket.emit(event, payload);
|
||||
});
|
||||
}
|
||||
|
||||
function logAdminEvent({ label, message, ip, meta = null, socketId = null }) {
|
||||
const entry = {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
label: label || null,
|
||||
message: message || '',
|
||||
ip: ip || null,
|
||||
meta: meta || null,
|
||||
socketId: socketId || null,
|
||||
};
|
||||
pushEntry(entry);
|
||||
emitToAdmins('adminlog:entry', entry);
|
||||
}
|
||||
|
||||
function hydrateSocket(socket) {
|
||||
if (!socket || !isAdminSocket(socket)) return;
|
||||
socket.emit('adminlog:init', history);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
hydrateSocket(socket);
|
||||
const ip = getSocketIp(socket);
|
||||
if (ip) {
|
||||
logAdminEvent({
|
||||
label: 'socket',
|
||||
message: 'Socket connected',
|
||||
ip,
|
||||
meta: { role: getRole(socket) },
|
||||
socketId: socket.id,
|
||||
});
|
||||
}
|
||||
socket.on('disconnect', () => {
|
||||
const disconnectIp = getSocketIp(socket);
|
||||
if (!disconnectIp) return;
|
||||
logAdminEvent({
|
||||
label: 'socket',
|
||||
message: 'Socket disconnected',
|
||||
ip: disconnectIp,
|
||||
meta: { role: getRole(socket) },
|
||||
socketId: socket.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket, role }) => {
|
||||
if (!socket) return;
|
||||
if (!isAdminRole(role)) return;
|
||||
hydrateSocket(socket);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
logAdminEvent,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('adminReasonService');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
|
||||
const MAX_REASON_LENGTH = 240;
|
||||
|
||||
let cache = null;
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load admin reason', err.message);
|
||||
}
|
||||
cache = null;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function saveStore(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
cache = next;
|
||||
}
|
||||
|
||||
function normalizeText(input) {
|
||||
if (typeof input !== 'string') return '';
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getAdminReason() {
|
||||
return loadStore();
|
||||
}
|
||||
|
||||
function setAdminReason(text, meta = {}) {
|
||||
const clean = normalizeText(text);
|
||||
if (!clean) {
|
||||
throw new Error('Reason text required');
|
||||
}
|
||||
if (clean.length > MAX_REASON_LENGTH) {
|
||||
throw new Error(`Reason too long (max ${MAX_REASON_LENGTH} chars)`);
|
||||
}
|
||||
const payload = {
|
||||
text: clean,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
function clearAdminReason(meta = {}) {
|
||||
const payload = {
|
||||
text: null,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('adminReason:set', ({ text } = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Not authorized' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
text == null || String(text).trim() === ''
|
||||
? clearAdminReason({ by: socket?.data?.user?.username || socket?.id })
|
||||
: setAdminReason(text, { by: socket?.data?.user?.username || socket?.id });
|
||||
cb({ success: true, reason: result });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
MAX_REASON_LENGTH,
|
||||
};
|
||||
@@ -1,15 +1,8 @@
|
||||
const io = require('../globals/io');
|
||||
|
||||
const COLORS = {
|
||||
info: '#2196f3',
|
||||
success: '#4caf50',
|
||||
warn: '#f0b651',
|
||||
error: '#e53935',
|
||||
};
|
||||
|
||||
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||
const payload = {
|
||||
color: color || COLORS.info,
|
||||
color: color || '#2196f3',
|
||||
title,
|
||||
message,
|
||||
ts,
|
||||
@@ -19,6 +12,5 @@ function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COLORS,
|
||||
sendAlert,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('assignment');
|
||||
const { MODES, getMode, modeEvents } = require('./modeManager');
|
||||
const { roleEvents, getRole, isAdmin } = require('./roleService');
|
||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
const socketRefs = new Map(); // socketId -> socket
|
||||
@@ -28,14 +28,20 @@ roleEvents.on('change', ({ socket, role }) => {
|
||||
});
|
||||
|
||||
modeEvents.on('change', (mode) => {
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
// release non-admin drivers
|
||||
if (mode === MODES.ADMIN) {
|
||||
for (const [socketId, roverId] of assignments.entries()) {
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (socket && !isAdmin(socket)) {
|
||||
releaseAssignment(socket, roverId);
|
||||
}
|
||||
}
|
||||
} else if (mode === MODES.LOCKDOWN) {
|
||||
for (const [socketId, roverId] of assignments.entries()) {
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (socket && !isLockdownAdmin(socket)) {
|
||||
releaseAssignment(socket, roverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
reassignWaiting();
|
||||
});
|
||||
@@ -48,12 +54,29 @@ roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
||||
if (open) {
|
||||
reassignWaiting();
|
||||
} else {
|
||||
reassignFromRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ action }) => {
|
||||
if (action === 'removed' || action === 'upsert') {
|
||||
reassignWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
||||
if (!socketId || !roverId) return;
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (!socket) return;
|
||||
assignments.set(socketId, roverId);
|
||||
waiting.delete(socketId);
|
||||
assignmentEvents.emit('update', socketId);
|
||||
});
|
||||
|
||||
function assignSocket(socket) {
|
||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||
return;
|
||||
@@ -62,7 +85,7 @@ function assignSocket(socket) {
|
||||
if (assignments.has(socket.id)) {
|
||||
return;
|
||||
}
|
||||
const target = pickRover();
|
||||
const target = pickRover(socket);
|
||||
if (!target) {
|
||||
waiting.add(socket.id);
|
||||
logger.info('No rover available, user waiting', socket.id);
|
||||
@@ -129,19 +152,60 @@ function releaseAssignment(socket, roverId) {
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
}
|
||||
|
||||
function pickRover() {
|
||||
function forceRelease(roverId, socketId) {
|
||||
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
|
||||
if (assignments.get(socketId) === roverId) {
|
||||
assignments.delete(socketId);
|
||||
}
|
||||
waiting.delete(socketId);
|
||||
if (socket) {
|
||||
roverManager.releaseControl(roverId, socket);
|
||||
logger.info('Force released socket from rover', socketId, roverId);
|
||||
} else {
|
||||
logger.warn('Force release: socket not found', socketId, roverId);
|
||||
}
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
|
||||
function pickRover(socket) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
return null;
|
||||
}
|
||||
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||
if (!rover || rover.locked) return false;
|
||||
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
|
||||
if (!access.ok) return false;
|
||||
return true;
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
candidates.sort((a, b) => a.drivers.size - b.drivers.size);
|
||||
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);
|
||||
candidates.sort((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;
|
||||
});
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
@@ -161,4 +225,16 @@ function describeAssignment(socketId) {
|
||||
module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
if (!socket || !roverId) return;
|
||||
const previous = assignments.get(socket.id);
|
||||
if (previous && previous !== roverId && releasePrevious) {
|
||||
roverManager.releaseControl(previous, socket);
|
||||
}
|
||||
assignments.set(socket.id, roverId);
|
||||
waiting.delete(socket.id);
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const roverManager = require('./roverManager');
|
||||
const turnService = require('./turnService');
|
||||
const { isVerified } = require('./verificationService');
|
||||
const videoSessions = require('./videoSessions');
|
||||
|
||||
const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const audioForwardConfig = config.audioForward || {};
|
||||
const mediaConfig = config.media || {};
|
||||
const serviceEnabled = audioForwardConfig.enabled !== false;
|
||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||
const streamSuffix =
|
||||
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
? audioForwardConfig.streamSuffix.trim()
|
||||
: '-fwd';
|
||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
: 8 * 1024 * 1024;
|
||||
|
||||
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
|
||||
const workers = new Map(); // roverId -> worker
|
||||
const whipOwners = new Map(); // roverId -> socketId
|
||||
|
||||
function publishStateChange(roverId) {
|
||||
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
|
||||
}
|
||||
|
||||
function setState(roverId, next = {}) {
|
||||
const prev = states.get(roverId) || {};
|
||||
const merged = {
|
||||
state: next.state || prev.state || 'idle',
|
||||
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
|
||||
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
|
||||
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
states.set(roverId, merged);
|
||||
publishStateChange(roverId);
|
||||
}
|
||||
|
||||
function getAudioForwardState() {
|
||||
const payload = {};
|
||||
states.forEach((entry, roverId) => {
|
||||
payload[roverId] = { ...entry };
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeRoverId(roverId) {
|
||||
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
|
||||
}
|
||||
|
||||
function sanitizeFileStem(name) {
|
||||
return String(name || 'upload')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function extFromUpload(name, mime) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
const lowerMime = String(mime || '').toLowerCase();
|
||||
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
|
||||
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
|
||||
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
|
||||
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
|
||||
}
|
||||
|
||||
function ensureVipVerified(socket) {
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioForwardPermission(socket, roverId) {
|
||||
ensureVipVerified(socket);
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
||||
}
|
||||
if (!turnService.canDrive(roverId, socket)) {
|
||||
throw new Error('Only the current driver can play audio');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFifo(fifoPath) {
|
||||
try {
|
||||
const stat = fs.statSync(fifoPath);
|
||||
if (stat.isFIFO()) return;
|
||||
fs.unlinkSync(fifoPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function forcePublishStreamMode(rawUrl) {
|
||||
const value = String(rawUrl || '').trim();
|
||||
if (!value) return '';
|
||||
if (!/[?&]streamid=#!::/.test(value)) return value;
|
||||
if (/,m=publish\b/.test(value)) return value;
|
||||
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
||||
}
|
||||
|
||||
function resolveForwardUrl(roverId) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
const configured = record?.meta?.media?.audioForwardUrl;
|
||||
if (configured) return forcePublishStreamMode(configured);
|
||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
|
||||
roverId + streamSuffix,
|
||||
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
||||
}
|
||||
|
||||
function resolveForwardPathId(roverId) {
|
||||
return `${roverId}${streamSuffix}`;
|
||||
}
|
||||
|
||||
function getMediaPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
if (!base) return '';
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return String(base).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhipUrl(pathId) {
|
||||
const prefix = getMediaPrefix();
|
||||
if (!prefix) {
|
||||
throw new Error('Server media base URL missing');
|
||||
}
|
||||
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
|
||||
}
|
||||
|
||||
function spawnFfmpeg(roverId, tag, args, options = {}) {
|
||||
const proc = spawn(ffmpegBin, args, {
|
||||
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
|
||||
});
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim();
|
||||
if (!text) return;
|
||||
logger.warn(`${tag} stderr`, { roverId, text });
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
function stopProc(proc, graceMs = 1200) {
|
||||
if (!proc || proc.killed) return;
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, graceMs);
|
||||
}
|
||||
|
||||
function buildPublisherArgs(fifoPath, outputUrl) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-ac',
|
||||
'1',
|
||||
'-i',
|
||||
fifoPath,
|
||||
'-c:a',
|
||||
'libopus',
|
||||
'-b:a',
|
||||
'24000',
|
||||
'-ar:a',
|
||||
'16000',
|
||||
'-ac:a',
|
||||
'1',
|
||||
'-application',
|
||||
'lowdelay',
|
||||
'-frame_duration',
|
||||
'10',
|
||||
'-compression_level',
|
||||
'0',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flush_packets',
|
||||
'1',
|
||||
'-muxdelay',
|
||||
'0',
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-f',
|
||||
'mpegts',
|
||||
outputUrl,
|
||||
];
|
||||
}
|
||||
|
||||
function buildSilenceWriterArgs() {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'anullsrc=channel_layout=mono:sample_rate=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function buildUploadWriterArgs(filePath) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-i',
|
||||
filePath,
|
||||
'-vn',
|
||||
'-af',
|
||||
'aresample=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function attachWriterPipe(worker, proc) {
|
||||
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
|
||||
writer.on('error', (err) => {
|
||||
const code = err?.code || 'unknown';
|
||||
if (code !== 'EPIPE') {
|
||||
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
proc.stdout.on('error', (err) => {
|
||||
logger.warn('writer stdout error', {
|
||||
roverId: worker?.roverId,
|
||||
code: err?.code || 'unknown',
|
||||
message: err?.message || String(err),
|
||||
});
|
||||
});
|
||||
proc.stdout.pipe(writer);
|
||||
proc.on('exit', () => {
|
||||
writer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupUploadFile(worker) {
|
||||
if (!worker?.activeUploadPath) return;
|
||||
try {
|
||||
fs.unlinkSync(worker.activeUploadPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
worker.activeUploadPath = null;
|
||||
}
|
||||
|
||||
function stopContentProc(worker) {
|
||||
if (!worker) return;
|
||||
if (worker.contentProc) {
|
||||
stopProc(worker.contentProc);
|
||||
}
|
||||
worker.contentProc = null;
|
||||
worker.contentKind = null;
|
||||
}
|
||||
|
||||
function startSilenceWriter(roverId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeOwnerSocketId = null;
|
||||
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'silence';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
if (code === 0 || signal === 'SIGTERM') return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'silence',
|
||||
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (workers.has(roverId)) startSilenceWriter(roverId);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function startUploadWriter(roverId, filePath, ownerSocketId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeUploadPath = filePath;
|
||||
worker.activeOwnerSocketId = ownerSocketId || null;
|
||||
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'upload';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
|
||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'upload',
|
||||
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureWorker(roverId) {
|
||||
if (!serviceEnabled) throw new Error('Audio forward disabled');
|
||||
if (!roverId) throw new Error('roverId required');
|
||||
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) throw new Error('Rover offline');
|
||||
|
||||
if (workers.has(roverId)) return workers.get(roverId);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
|
||||
ensureFifo(fifoPath);
|
||||
const outputUrl = resolveForwardUrl(roverId);
|
||||
|
||||
const keepaliveFd = fs.openSync(fifoPath, 'r+');
|
||||
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
|
||||
|
||||
const worker = {
|
||||
roverId,
|
||||
fifoPath,
|
||||
keepaliveFd,
|
||||
outputUrl,
|
||||
publisherProc: publisher,
|
||||
contentProc: null,
|
||||
contentKind: null,
|
||||
writerSeq: 0,
|
||||
activeOwnerSocketId: null,
|
||||
activeUploadPath: null,
|
||||
stopping: false,
|
||||
};
|
||||
workers.set(roverId, worker);
|
||||
|
||||
publisher.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.publisherProc !== publisher || current.stopping) return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: current.contentKind || 'publish',
|
||||
error: `publisher exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
startSilenceWriter(roverId);
|
||||
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
|
||||
return worker;
|
||||
}
|
||||
|
||||
function stopWorker(roverId) {
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, whipOwner);
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker) return;
|
||||
|
||||
worker.stopping = true;
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
stopProc(worker.publisherProc);
|
||||
|
||||
try {
|
||||
fs.closeSync(worker.keepaliveFd);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(worker.fifoPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
workers.delete(roverId);
|
||||
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function writeUploadFile(roverId, payload = {}) {
|
||||
const { name, mime, dataBase64 } = payload || {};
|
||||
const ext = extFromUpload(name, mime);
|
||||
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
|
||||
if (!encoded) throw new Error('Upload payload missing');
|
||||
|
||||
const bytes = Buffer.from(encoded, 'base64');
|
||||
if (!bytes.length) throw new Error('Upload decode failed');
|
||||
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
|
||||
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
|
||||
stopWhipForRover(roverId, 'upload_override');
|
||||
ensureWorker(roverId);
|
||||
const uploadPath = writeUploadFile(roverId, payload);
|
||||
startUploadWriter(roverId, uploadPath, ownerSocketId);
|
||||
}
|
||||
|
||||
function stopPlayback(roverId) {
|
||||
stopWhipForRover(roverId, 'stop_playback');
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
function revokeWhipSessionForRover(roverId, ownerSocketId) {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
const pathId = resolveForwardPathId(roverId);
|
||||
videoSessions.revokeWhere(
|
||||
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
|
||||
);
|
||||
}
|
||||
|
||||
function stopWhipForRover(roverId, reason = 'unknown') {
|
||||
const ownerSocketId = whipOwners.get(roverId);
|
||||
if (!ownerSocketId) return;
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, ownerSocketId);
|
||||
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'mic-whip', error: err?.message || String(err), startedAt: null });
|
||||
}
|
||||
}
|
||||
|
||||
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
|
||||
if (whipOwners.get(roverId) === ownerSocketId) {
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (!ownerIsDriver || !ownerCanDrive) {
|
||||
stopWhipForRover(roverId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
|
||||
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (ownerIsDriver && ownerCanDrive) return;
|
||||
|
||||
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (!roverId) return;
|
||||
if (action === 'removed') {
|
||||
stopWorker(roverId);
|
||||
return;
|
||||
}
|
||||
if (action === 'upsert' && serviceEnabled) {
|
||||
if (whipOwners.has(roverId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
|
||||
if (!socketId || !roverId) return;
|
||||
if (action === 'remove' || action === 'add') {
|
||||
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
|
||||
}
|
||||
});
|
||||
|
||||
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
|
||||
if (!roverId) return;
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
|
||||
}
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload') return;
|
||||
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
const roverId = String(payload?.roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, roverId);
|
||||
playUploadedAudio(roverId, payload, socket.id);
|
||||
cb({ success: true, roverId });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
const worker = workers.get(normalized);
|
||||
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
|
||||
throw new Error('Upload playback is owned by another session');
|
||||
}
|
||||
stopPlayback(normalized);
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
stopWorker(normalized);
|
||||
whipOwners.set(normalized, socket.id);
|
||||
const pathId = resolveForwardPathId(normalized);
|
||||
revokeWhipSessionForRover(normalized, socket.id);
|
||||
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
|
||||
const whipUrl = buildWhipUrl(pathId);
|
||||
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('WHIP session not owned by this client');
|
||||
}
|
||||
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('Mic forwarding is owned by another session');
|
||||
}
|
||||
stopWhipForRover(normalized, 'client_stop');
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
workers.forEach((worker, roverId) => {
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
|
||||
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
|
||||
if (ownerSocketId !== socket.id) continue;
|
||||
stopWhipForRover(roverId, 'socket_disconnect');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { issueCommand } = require('./commandService');
|
||||
|
||||
const audioLevelsEvents = new EventEmitter();
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json');
|
||||
const config = loadConfig();
|
||||
const configuredDefaults = config.audioLevels || {};
|
||||
|
||||
const DEFAULTS = {
|
||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||
};
|
||||
|
||||
function clampGain(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(4, num));
|
||||
}
|
||||
|
||||
function normalizeStore(raw = {}) {
|
||||
return {
|
||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||
};
|
||||
}
|
||||
|
||||
let state = null;
|
||||
|
||||
function loadState() {
|
||||
if (state) return state;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
state = normalizeStore(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load audio levels store', err.message);
|
||||
}
|
||||
state = normalizeStore({});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function persistState(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const normalized = normalizeStore(next);
|
||||
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, STORE_PATH);
|
||||
state = normalized;
|
||||
return state;
|
||||
}
|
||||
|
||||
function getAudioLevels() {
|
||||
const current = loadState();
|
||||
return {
|
||||
hornGain: current.hornGain,
|
||||
ttsGain: current.ttsGain,
|
||||
forwardGain: current.forwardGain,
|
||||
updatedAt: current.updatedAt,
|
||||
updatedBy: current.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function emitChange(reason = 'update') {
|
||||
audioLevelsEvents.emit('change', {
|
||||
reason,
|
||||
levels: getAudioLevels(),
|
||||
});
|
||||
}
|
||||
|
||||
function pushLevelsToRover(roverId) {
|
||||
if (!roverId) return;
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'audioLevels',
|
||||
audioLevels: getAudioLevels(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function pushLevelsToAllRovers() {
|
||||
roverManager.rovers.forEach((record, roverId) => {
|
||||
if (record?.ws) {
|
||||
pushLevelsToRover(roverId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setAudioLevels(input = {}, actor = null) {
|
||||
const current = loadState();
|
||||
const next = {
|
||||
...current,
|
||||
hornGain: clampGain(input.hornGain, current.hornGain),
|
||||
ttsGain: clampGain(input.ttsGain, current.ttsGain),
|
||||
forwardGain: clampGain(input.forwardGain, current.forwardGain),
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: actor,
|
||||
};
|
||||
persistState(next);
|
||||
pushLevelsToAllRovers();
|
||||
emitChange('set');
|
||||
return getAudioLevels();
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (action === 'upsert' && roverId) {
|
||||
pushLevelsToRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||
cb({ success: true, levels: getAudioLevels() });
|
||||
});
|
||||
|
||||
socket.on('audioLevels:set', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
if (!isAdmin(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
const actor = socket?.data?.user?.username || null;
|
||||
const levels = setAudioLevels(payload || {}, actor);
|
||||
cb({ success: true, levels });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadState();
|
||||
|
||||
module.exports = {
|
||||
getAudioLevels,
|
||||
setAudioLevels,
|
||||
pushLevelsToRover,
|
||||
audioLevelsEvents,
|
||||
};
|
||||
@@ -3,6 +3,7 @@ const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('authService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { clearLockdownTimer } = require('./lockdownGuard');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { setRole } = require('./roleService');
|
||||
|
||||
const config = loadConfig();
|
||||
@@ -41,6 +42,9 @@ io.on('connection', (socket) => {
|
||||
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
||||
try {
|
||||
const admin = await authenticate(username, password);
|
||||
if (getMode() === MODES.LOCKDOWN && !admin.lockdown) {
|
||||
throw new Error('Lockdown admins only');
|
||||
}
|
||||
const role = admin.lockdown ? 'lockdown' : 'admin';
|
||||
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
||||
setRole(socket, role);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
const logger = require('../globals/logger').child('batteryManager');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { managerEvents, lockRover, rovers } = require('./roverManager');
|
||||
|
||||
const STATES = {
|
||||
NORMAL: 'normal',
|
||||
WARN: 'warn',
|
||||
URGENT: 'urgent',
|
||||
DOCKED: 'docked',
|
||||
CHARGING: 'charging',
|
||||
FULL: 'full',
|
||||
LOCKED: 'locked',
|
||||
};
|
||||
|
||||
const WAITING_UNLOCK_MS = 5 * 60 * 1000;
|
||||
|
||||
const roverState = new Map(); // roverId -> state snapshot
|
||||
|
||||
function getState(roverId) {
|
||||
if (!roverState.has(roverId)) {
|
||||
roverState.set(roverId, {
|
||||
lastPercent: null,
|
||||
warned: false,
|
||||
urgent: false,
|
||||
onDock: false,
|
||||
dockedCharging: false,
|
||||
charging: false,
|
||||
batteryLocked: false,
|
||||
waitingSince: null,
|
||||
});
|
||||
}
|
||||
return roverState.get(roverId);
|
||||
}
|
||||
|
||||
function shouldLock(record) {
|
||||
return record.lockReason == null || record.lockReason === 'battery';
|
||||
}
|
||||
|
||||
function handleSensorEvent({ roverId, sensors, batteryState }) {
|
||||
if (!roverId) return;
|
||||
const record = rovers.get(roverId);
|
||||
if (!record) return;
|
||||
const state = getState(roverId);
|
||||
const config = record.meta?.battery || {};
|
||||
const lockable = shouldLock(record);
|
||||
|
||||
if (batteryState?.percent != null) {
|
||||
state.lastPercent = batteryState.percent;
|
||||
}
|
||||
|
||||
if (!state.warned && batteryState?.warnActive) {
|
||||
state.warned = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.warn',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
if (!state.urgent && batteryState?.urgentActive) {
|
||||
state.urgent = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.urgent',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
|
||||
const chargingState = sensors?.chargingState?.code;
|
||||
const chargingSources = sensors?.chargingSources;
|
||||
const onDock = Boolean(chargingSources?.homeBase);
|
||||
if (onDock && !state.onDock) {
|
||||
state.onDock = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.docked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
} else if (!onDock && state.onDock) {
|
||||
state.onDock = false;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.undocked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
const chargingLabel = sensors?.chargingState?.label?.toLowerCase();
|
||||
const chargingByLabel =
|
||||
chargingLabel === 'waiting' || chargingLabel === 'full charging' || chargingLabel === 'trickle charging';
|
||||
const chargingByCode = chargingState === 2 || chargingState === 3 || chargingState === 4;
|
||||
const isCharging = chargingByLabel || chargingByCode;
|
||||
if (isCharging && !state.charging) {
|
||||
state.charging = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.charging.start',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
} else if (!isCharging && state.charging) {
|
||||
state.charging = false;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.charging.stop',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
const dockedCharging = onDock && isCharging;
|
||||
if (dockedCharging && !state.dockedCharging) {
|
||||
state.dockedCharging = true;
|
||||
} else if (!dockedCharging && state.dockedCharging) {
|
||||
state.dockedCharging = false;
|
||||
}
|
||||
|
||||
const waitingState = chargingState === 4;
|
||||
if (waitingState && state.waitingSince == null) {
|
||||
state.waitingSince = Date.now();
|
||||
} else if (!waitingState && state.waitingSince != null) {
|
||||
state.waitingSince = null;
|
||||
}
|
||||
const waitingLongEnough =
|
||||
waitingState && state.waitingSince != null && Date.now() - state.waitingSince >= WAITING_UNLOCK_MS;
|
||||
|
||||
const warnThreshold =
|
||||
typeof config?.Warn === 'number' ? config.Warn : batteryState?.warn ?? null;
|
||||
const fullThreshold =
|
||||
typeof config?.Full === 'number' ? config.Full : batteryState?.full ?? null;
|
||||
const chargeMah = batteryState?.charge ?? null;
|
||||
let waitingPercent = batteryState?.percent ?? null;
|
||||
if (chargeMah != null && warnThreshold != null && fullThreshold != null && fullThreshold > warnThreshold) {
|
||||
waitingPercent = (chargeMah - warnThreshold) / (fullThreshold - warnThreshold);
|
||||
waitingPercent = Math.max(0, Math.min(1, waitingPercent));
|
||||
}
|
||||
|
||||
const isFull =
|
||||
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
||||
(batteryState?.percent != null && batteryState.percent >= 0.99);
|
||||
|
||||
const needsCharge = state.warned;
|
||||
|
||||
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
||||
logger.info('Auto-locking rover for charging', { roverId });
|
||||
lockRover(roverId, true, { reason: 'battery' });
|
||||
state.batteryLocked = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.locked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
|
||||
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.01;
|
||||
// const waitingHalfOrMore = true;
|
||||
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
|
||||
|
||||
if (shouldUnlock) {
|
||||
logger.info('Unlocking rover after charge', {
|
||||
roverId,
|
||||
isFull,
|
||||
docked: dockedCharging,
|
||||
waitingState,
|
||||
waitingPercent,
|
||||
waitedMs: state.waitingSince ? Date.now() - state.waitingSince : null,
|
||||
});
|
||||
lockRover(roverId, false, { reason: 'battery' });
|
||||
state.batteryLocked = false;
|
||||
state.warned = false;
|
||||
state.urgent = false;
|
||||
state.waitingSince = null;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.unlocked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleLockEvent({ roverId, locked, reason }) {
|
||||
const state = getState(roverId);
|
||||
if (!state) return;
|
||||
if (!locked) {
|
||||
state.batteryLocked = false;
|
||||
state.waitingSince = null;
|
||||
return;
|
||||
}
|
||||
state.batteryLocked = reason === 'battery';
|
||||
}
|
||||
|
||||
managerEvents.on('sensor', handleSensorEvent);
|
||||
managerEvents.on('lock', handleLockEvent);
|
||||
@@ -0,0 +1,589 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('chatService');
|
||||
const { publishEvent, subscribe } = require('./eventBus');
|
||||
const { getRole } = require('./roleService');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { describeAssignment } = require('./assignmentService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { issueCommand } = require('./commandService');
|
||||
const { getAdminReason } = require('./adminReasonService');
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const rateBuckets = new Map(); // socketId -> [timestamps]
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
const history = [];
|
||||
|
||||
// Words in this list are removed from the profanity dataset entirely.
|
||||
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
|
||||
const normalizedProfanityAllowlist = new Set(PROFANITY_ALLOWLIST
|
||||
.filter((term) => typeof term === 'string')
|
||||
.map((term) => term.trim().toLowerCase())
|
||||
.filter(Boolean));
|
||||
const profanityDataset = new DataSet()
|
||||
.addAll(englishDataset)
|
||||
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
|
||||
.build();
|
||||
const profanityMatcher = new RegExpMatcher({
|
||||
...profanityDataset,
|
||||
...englishRecommendedTransformers,
|
||||
whitelistedTerms: profanityDataset.whitelistedTerms,
|
||||
});
|
||||
const DUPLICATE_WINDOW_MS = 15000;
|
||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
||||
const typingBySocket = new Map(); // socketId -> boolean
|
||||
const TYPING_START_NOTE = 72;
|
||||
const TYPING_SEND_NOTE = 79;
|
||||
const TYPING_NOTE_DURATION = 8;
|
||||
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
|
||||
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
|
||||
let lastAccessNoticeAt = 0;
|
||||
|
||||
function withinRateLimit(socketId) {
|
||||
const now = Date.now();
|
||||
const entries = rateBuckets.get(socketId) || [];
|
||||
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
|
||||
next.push(now);
|
||||
rateBuckets.set(socketId, next);
|
||||
return next.length <= RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
function hasProfanity(text) {
|
||||
if (typeof text !== 'string' || !text) return false;
|
||||
return profanityMatcher.hasMatch(text);
|
||||
}
|
||||
|
||||
function isDuplicate(socketId, text) {
|
||||
const prev = lastMessageBySocket.get(socketId);
|
||||
const now = Date.now();
|
||||
if (!prev) {
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return false;
|
||||
}
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
|
||||
}
|
||||
|
||||
function isKeymash(text) {
|
||||
if (!text) return false;
|
||||
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
|
||||
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
|
||||
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveRoverId(socketId) {
|
||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
if (primary) return primary;
|
||||
const assignment = describeAssignment(socketId);
|
||||
return assignment?.roverId || null;
|
||||
}
|
||||
|
||||
function resolveRoverColor(roverId) {
|
||||
if (!roverId) return null;
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.color || null;
|
||||
}
|
||||
|
||||
function isPrivateClosedRoverId(roverId) {
|
||||
if (!roverId) return false;
|
||||
return roverManager.canReplayRoverId(roverId) !== true;
|
||||
}
|
||||
|
||||
function normalizeUserText(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function buildMessage(socket, text, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
socketId: socket?.id || null,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord: Boolean(meta.fromDiscord),
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
roverCtx: meta.roverCtx || null,
|
||||
text,
|
||||
tts: meta.tts || null,
|
||||
system: Boolean(meta.system),
|
||||
};
|
||||
}
|
||||
|
||||
function isChargingFromSensors(sensors = {}) {
|
||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
|
||||
return true;
|
||||
}
|
||||
const code = sensors?.chargingState?.code;
|
||||
return code === 2 || code === 3 || code === 4;
|
||||
}
|
||||
|
||||
function buildRoverCtxSnapshot(roverId) {
|
||||
if (!roverId) return null;
|
||||
const key = String(roverId);
|
||||
const record = roverManager.rovers.get(key);
|
||||
if (!record) return null;
|
||||
const sensors = record?.lastSensor?.decoded || {};
|
||||
const batteryState = record?.batteryState || null;
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
|
||||
const charging = isChargingFromSensors(sensors);
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const wheelsOffGround = Boolean(
|
||||
sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight,
|
||||
);
|
||||
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
|
||||
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
|
||||
const latestBumps =
|
||||
(sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) +
|
||||
(sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
|
||||
const light = sensors?.lightBumper || {};
|
||||
const contactState = docked
|
||||
? 'clear'
|
||||
: latestBumps >= 0.5
|
||||
? 'bumps_recent'
|
||||
: sensors?.wall ||
|
||||
light.left ||
|
||||
light.frontLeft ||
|
||||
light.centerLeft ||
|
||||
light.centerRight ||
|
||||
light.frontRight ||
|
||||
light.right
|
||||
? 'wall_brush'
|
||||
: 'clear';
|
||||
const hazardState = docked
|
||||
? 'normal'
|
||||
: sensors?.virtualWall
|
||||
? 'virtual_wall_seen'
|
||||
: sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight
|
||||
? 'cliff_alert'
|
||||
: 'normal';
|
||||
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
|
||||
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
|
||||
const activityScore = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
baseScore +
|
||||
(contactState === 'wall_brush' ? 6 : 0) +
|
||||
(contactState === 'bumps_recent' ? 12 : 0) +
|
||||
(hazardState !== 'normal' ? 8 : 0) +
|
||||
(wheelsOffGround ? -20 : 0),
|
||||
),
|
||||
);
|
||||
const activityBand =
|
||||
activityScore >= 75
|
||||
? 'intense'
|
||||
: activityScore >= 50
|
||||
? 'high'
|
||||
: activityScore >= 25
|
||||
? 'medium'
|
||||
: activityScore >= 8
|
||||
? 'low'
|
||||
: 'idle';
|
||||
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
|
||||
let statusTag = 'idle';
|
||||
if (charging) {
|
||||
statusTag = 'charging';
|
||||
} else if (docked) {
|
||||
statusTag = 'docked';
|
||||
} else if (driverSocketId && moving) {
|
||||
statusTag = 'driving';
|
||||
} else if (driverSocketId) {
|
||||
statusTag = 'active-idle';
|
||||
}
|
||||
return {
|
||||
id: key,
|
||||
status_tag: statusTag,
|
||||
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
|
||||
docked,
|
||||
charging,
|
||||
wheels_off_ground: wheelsOffGround,
|
||||
contact_state: contactState,
|
||||
hazard_state: hazardState,
|
||||
mobility_state: mobilityState,
|
||||
activity_score: activityScore,
|
||||
activity_band: activityBand,
|
||||
activity_trend: 'steady',
|
||||
activity_30s: {
|
||||
distance_m: latestDistanceM,
|
||||
turn_deg: latestTurnDeg,
|
||||
bumps: latestBumps,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypingPayload(socket, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
const socketId = socket?.id || null;
|
||||
const fromDiscord = Boolean(meta.fromDiscord);
|
||||
let typingId = meta.typingId || null;
|
||||
if (!typingId) {
|
||||
if (fromDiscord) {
|
||||
if (meta.discordUserId) {
|
||||
typingId = `discord:${meta.discordUserId}`;
|
||||
} else if (meta.discordUserName) {
|
||||
typingId = `discord:${meta.discordUserName}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `discord:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'discord:unknown';
|
||||
}
|
||||
} else if (socketId) {
|
||||
typingId = `socket:${socketId}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `socket:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'socket:unknown';
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
typingId,
|
||||
isTyping: Boolean(meta.isTyping),
|
||||
socketId,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord,
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(message) {
|
||||
history.push(message);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentMessages(limit = 20, options = {}) {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||
const includeSystem = options?.includeSystem !== false;
|
||||
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
||||
return source.slice(-safeLimit);
|
||||
}
|
||||
|
||||
function broadcastMessage(message) {
|
||||
pushHistory(message);
|
||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||
}
|
||||
|
||||
function broadcastTyping(payload) {
|
||||
publishEvent({ source: 'chat', type: 'chat:typing', payload });
|
||||
}
|
||||
|
||||
function playTypingNote(roverId, note, socketId) {
|
||||
if (!roverId) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
song: {
|
||||
notes: [{ note, duration: TYPING_NOTE_DURATION }],
|
||||
},
|
||||
});
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone sent', { roverId, note, socketId });
|
||||
} catch (err) {
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone failed', { roverId, note, socketId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtsOptions(raw = {}) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const speak = raw.speak !== false;
|
||||
if (!speak) return null;
|
||||
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
|
||||
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
|
||||
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
|
||||
if (typeof pitch === 'number') {
|
||||
pitch = Math.max(0, Math.min(99, pitch));
|
||||
}
|
||||
return { speak, engine, voice, pitch };
|
||||
}
|
||||
|
||||
function buildAccessNoticeText(mode, reasonText) {
|
||||
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
|
||||
const reason = reasonText ? ` Reason: ${reasonText}` : '';
|
||||
return `Heads up: the server is in ${label} mode.${reason}`;
|
||||
}
|
||||
|
||||
function shouldSendAccessNotice(message) {
|
||||
if (!message?.text || message.system) return false;
|
||||
const mode = getMode();
|
||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||
const now = Date.now();
|
||||
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
|
||||
lastAccessNoticeAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendSystemMessage(text) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return null;
|
||||
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
|
||||
const message = buildMessage(null, safe, {
|
||||
nickname: 'The Overseer',
|
||||
role: 'user',
|
||||
fromDiscord: false,
|
||||
system: true,
|
||||
});
|
||||
broadcastMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function maybeSendAccessNotice(message) {
|
||||
if (!shouldSendAccessNotice(message)) return;
|
||||
const reason = getAdminReason()?.text || '';
|
||||
const mode = getMode();
|
||||
const notice = buildAccessNoticeText(mode, reason);
|
||||
sendSystemMessage(notice);
|
||||
}
|
||||
|
||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
const role = getRole(socket);
|
||||
// if (role === 'spectator') {
|
||||
// cb({ error: 'Spectators cannot chat' });
|
||||
// return;
|
||||
// }
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) {
|
||||
cb({ error: 'Message required' });
|
||||
return;
|
||||
}
|
||||
if (!withinRateLimit(socket.id)) {
|
||||
cb({ error: 'Slow down' });
|
||||
return;
|
||||
}
|
||||
if (clean.length > 400) {
|
||||
cb({ error: 'Message too long' });
|
||||
return;
|
||||
}
|
||||
if (hasProfanity(clean)) {
|
||||
cb({ error: 'Message blocked' });
|
||||
return;
|
||||
}
|
||||
// if (isDuplicate(socket.id, clean)) {
|
||||
// cb({ error: 'Duplicate message' });
|
||||
// return;
|
||||
// }
|
||||
// if (isKeymash(clean)) {
|
||||
// cb({ error: 'Message looks like spam' });
|
||||
// return;
|
||||
// }
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
const ttsOptions = normalizeTtsOptions(tts);
|
||||
const message = buildMessage(socket, clean, {
|
||||
fromDiscord: false,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
tts: ttsOptions,
|
||||
});
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
const privateClosed = isPrivateClosedRoverId(message.roverId);
|
||||
if (privateClosed) {
|
||||
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
|
||||
maybeSpeak(socket, message, forcedTts);
|
||||
cb({ success: true, privateOnly: true });
|
||||
return;
|
||||
}
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
cb({ success: true });
|
||||
}
|
||||
|
||||
function maybeSpeak(socket, message, ttsOptions) {
|
||||
if (!ttsOptions || !message?.roverId) return;
|
||||
const record = roverManager.rovers.get(message.roverId);
|
||||
const audio = record?.meta?.audio || {};
|
||||
const ttsEnabled = Boolean(audio.ttsEnabled);
|
||||
if (!ttsEnabled) return;
|
||||
const { isQueuedDriver } = require('./turnService');
|
||||
if (
|
||||
!roverManager.canDrive(message.roverId, socket) &&
|
||||
!isQueuedDriver(message.roverId, socket?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
issueCommand(message.roverId, {
|
||||
type: 'tts',
|
||||
tts: {
|
||||
text: message.text,
|
||||
engine: ttsOptions.engine,
|
||||
voice: ttsOptions.voice,
|
||||
pitch: ttsOptions.pitch,
|
||||
speak: true,
|
||||
},
|
||||
});
|
||||
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
|
||||
} catch (err) {
|
||||
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function sendExternalMessage({
|
||||
text,
|
||||
nickname = 'Discord',
|
||||
role = 'admin',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
}) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean || clean.length > 400) {
|
||||
throw new Error('Message invalid');
|
||||
}
|
||||
if (hasProfanity(clean)) {
|
||||
throw new Error('Message blocked');
|
||||
}
|
||||
if (isKeymash(clean)) {
|
||||
throw new Error('Message looks like spam');
|
||||
}
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
throw new Error('Private rover chat is closed');
|
||||
}
|
||||
const message = buildMessage(null, clean, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
});
|
||||
logger.info('External chat message', { roverId, nickname });
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function sendExternalTyping({
|
||||
nickname = 'Discord',
|
||||
role = 'user',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
isTyping = true,
|
||||
}) {
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return null;
|
||||
}
|
||||
const payload = buildTypingPayload(null, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
isTyping,
|
||||
});
|
||||
broadcastTyping(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.emit('chat:init', history);
|
||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||
socket.on('chat:typing', (payload = {}) => {
|
||||
const isTyping = Boolean(payload?.isTyping);
|
||||
const wasTyping = typingBySocket.get(socket.id);
|
||||
if (isTyping) {
|
||||
typingBySocket.set(socket.id, true);
|
||||
if (!wasTyping) {
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||
}
|
||||
} else {
|
||||
typingBySocket.delete(socket.id);
|
||||
}
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
if (!typingBySocket.has(socket.id)) return;
|
||||
typingBySocket.delete(socket.id);
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
});
|
||||
|
||||
subscribe('chat:message', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:message', payload);
|
||||
});
|
||||
|
||||
subscribe('chat:typing', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:typing', payload);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
handleIncoming,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
sendSystemMessage,
|
||||
getRecentMessages,
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
const roverManager = require('./roverManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const logger = require('../globals/logger').child('commandService');
|
||||
|
||||
const pendingCommands = new Map(); // id -> { roverId }
|
||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||
const driveCooldowns = new Map(); // roverId -> blockedUntil
|
||||
|
||||
function issueCommand(roverId, payload) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
@@ -31,9 +34,27 @@ function handleAck(msg) {
|
||||
});
|
||||
}
|
||||
|
||||
function getRecentDriveActivity(windowMs, options = {}) {
|
||||
const now = Date.now();
|
||||
const results = [];
|
||||
for (const [roverId, info] of lastDriveActivity.entries()) {
|
||||
if (!info || now - info.ts > windowMs) continue;
|
||||
if (options.excludeAdmins && info.isAdmin) continue;
|
||||
results.push({ roverId, ...info });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function setDriveCooldown(roverId, durationMs) {
|
||||
if (!roverId || !durationMs) return;
|
||||
driveCooldowns.set(roverId, Date.now() + durationMs);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
issueCommand,
|
||||
handleAck,
|
||||
getRecentDriveActivity,
|
||||
setDriveCooldown,
|
||||
};
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
@@ -43,12 +64,59 @@ io.on('connection', (socket) => {
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
}
|
||||
if (!roverManager.canDrive(roverId, socket)) {
|
||||
throw new Error('Not your turn or no control');
|
||||
if (!type) {
|
||||
throw new Error('type required');
|
||||
}
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
const payload = data ? { ...data } : {};
|
||||
const isRebootCommand = type === 'reboot';
|
||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||
const isAdminSocket = isAdmin(socket);
|
||||
if (isRebootCommand && !isAdminSocket) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
if (!isSongCommand && !isRebootCommand && !roverManager.canDrive(roverId, socket)) {
|
||||
throw new Error('Not your turn or no control');
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
const reason = isLockdownAdmin(socket)
|
||||
? 'Drive blocked: cooldown'
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
if (left > 0 && right > 0) direction = 'forward';
|
||||
if (left < 0 && right < 0) direction = 'backward';
|
||||
lastDriveActivity.set(roverId, {
|
||||
ts: Date.now(),
|
||||
socketId: socket.id,
|
||||
direction,
|
||||
speed,
|
||||
isAdmin: isAdminSocket,
|
||||
});
|
||||
}
|
||||
}
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
try {
|
||||
const { recordActivity } = require('./turnService');
|
||||
recordActivity(roverId, socket.id);
|
||||
} catch (err) {
|
||||
// best effort; ignore activity update errors
|
||||
}
|
||||
reply({ id });
|
||||
} catch (err) {
|
||||
logger.warn('Command rejected', socket.id, err.message);
|
||||
@@ -59,3 +127,20 @@ io.on('connection', (socket) => {
|
||||
socket.on('command', handleCommand);
|
||||
socket.on('command:issue', handleCommand);
|
||||
});
|
||||
|
||||
function isSongRawPayload(payload) {
|
||||
if (!payload) return false;
|
||||
const raw = payload.raw;
|
||||
if (!raw) return false;
|
||||
let bytes = null;
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
bytes = raw;
|
||||
} else if (Array.isArray(raw)) {
|
||||
bytes = Buffer.from(raw);
|
||||
} else if (typeof raw === 'string') {
|
||||
bytes = Buffer.from(raw, 'base64');
|
||||
}
|
||||
if (!bytes || bytes.length === 0) return false;
|
||||
const opcode = bytes[0];
|
||||
return opcode === 140 || opcode === 141;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('communityGoalService');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'community-goal.json');
|
||||
const MAX_GOAL_LENGTH = 240;
|
||||
|
||||
let cache = null;
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load community goal', err.message);
|
||||
}
|
||||
cache = null;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function saveStore(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
cache = next;
|
||||
}
|
||||
|
||||
function normalizeText(input) {
|
||||
if (typeof input !== 'string') return '';
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getCommunityGoal() {
|
||||
return loadStore();
|
||||
}
|
||||
|
||||
function setCommunityGoal(text, meta = {}) {
|
||||
const clean = normalizeText(text);
|
||||
if (!clean) {
|
||||
throw new Error('Goal text required');
|
||||
}
|
||||
if (clean.length > MAX_GOAL_LENGTH) {
|
||||
throw new Error(`Goal too long (max ${MAX_GOAL_LENGTH} chars)`);
|
||||
}
|
||||
const payload = {
|
||||
text: clean,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
function clearCommunityGoal(meta = {}) {
|
||||
const payload = {
|
||||
text: null,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('communityGoal:set', ({ text } = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Not authorized' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
text == null || String(text).trim() === ''
|
||||
? clearCommunityGoal({ by: socket?.data?.user?.username || socket?.id })
|
||||
: setCommunityGoal(text, { by: socket?.data?.user?.username || socket?.id });
|
||||
cb({ success: true, goal: result });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getCommunityGoal,
|
||||
setCommunityGoal,
|
||||
clearCommunityGoal,
|
||||
MAX_GOAL_LENGTH,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../globals/logger').child('discordGuildStore');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'discord-guilds.json');
|
||||
const VALID_MODES = new Set(['global', 'private']);
|
||||
|
||||
let cache = null;
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load guild store', err.message);
|
||||
}
|
||||
cache = {};
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function saveStore(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
cache = next;
|
||||
}
|
||||
|
||||
function normalizeMode(input, fallback = 'global') {
|
||||
const candidate = String(input || '').trim().toLowerCase();
|
||||
if (VALID_MODES.has(candidate)) return candidate;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function listGuildConfigs() {
|
||||
const store = loadStore();
|
||||
return Object.values(store);
|
||||
}
|
||||
|
||||
function getGuildConfig(guildId) {
|
||||
if (!guildId) return null;
|
||||
const store = loadStore();
|
||||
return store[String(guildId)] || null;
|
||||
}
|
||||
|
||||
function setGuildConfig(guildId, { channelId, mode, webhookId, webhookToken }) {
|
||||
if (!guildId) {
|
||||
throw new Error('guildId required');
|
||||
}
|
||||
const store = { ...loadStore() };
|
||||
const now = Date.now();
|
||||
const key = String(guildId);
|
||||
const prev = store[key] || null;
|
||||
const next = {
|
||||
guildId: key,
|
||||
channelId: channelId != null ? String(channelId) : prev?.channelId || null,
|
||||
mode: normalizeMode(mode, prev?.mode || 'global'),
|
||||
webhookId: webhookId != null ? String(webhookId) : prev?.webhookId || null,
|
||||
webhookToken: webhookToken != null ? String(webhookToken) : prev?.webhookToken || null,
|
||||
createdAt: prev?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
store[key] = next;
|
||||
saveStore(store);
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeGuildConfig(guildId) {
|
||||
if (!guildId) return;
|
||||
const store = { ...loadStore() };
|
||||
delete store[String(guildId)];
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
setGuildConfig,
|
||||
removeGuildConfig,
|
||||
normalizeMode,
|
||||
VALID_MODES,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
const { app } = require('../globals/http');
|
||||
const { renderIndexHtml, renderOgImage } = require('./embedService');
|
||||
|
||||
app.get(['/', '/spectate', '/mini'], async (req, res) => {
|
||||
try {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
} catch (err) {
|
||||
res.status(500).send('Failed to render page');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/og/preview.png', async (req, res) => {
|
||||
try {
|
||||
const buffer = await renderOgImage();
|
||||
res.set('Cache-Control', 'public, max-age=60');
|
||||
res.type('png').send(buffer);
|
||||
} catch (err) {
|
||||
res.status(500).send('Failed to render embed image');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const { getMode } = require('./modeManager');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getActiveDrivers, getTurnQueues } = require('./turnService');
|
||||
const { getRoomCameras } = require('./roomCameraService');
|
||||
const { getRoomCameraState } = require('./roomCameraSnapshotService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
|
||||
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', 'public', 'index.html');
|
||||
const BITMAP_PATH = path.join(__dirname, '..', '..', 'public', 'bitmap.png');
|
||||
|
||||
const OG_WIDTH = 1200;
|
||||
const OG_HEIGHT = 630;
|
||||
const BASE_BG = { r: 8, g: 12, b: 22 };
|
||||
|
||||
let cachedIndexHtml = null;
|
||||
let cachedIndexMtimeMs = 0;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
async function loadIndexHtml() {
|
||||
const stat = await fsp.stat(INDEX_HTML_PATH);
|
||||
if (!cachedIndexHtml || stat.mtimeMs !== cachedIndexMtimeMs) {
|
||||
cachedIndexHtml = await fsp.readFile(INDEX_HTML_PATH, 'utf8');
|
||||
cachedIndexMtimeMs = stat.mtimeMs;
|
||||
}
|
||||
return cachedIndexHtml;
|
||||
}
|
||||
|
||||
function getBaseUrl(req) {
|
||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||
const forwardedHost = req.headers['x-forwarded-host'];
|
||||
const proto = forwardedProto ? forwardedProto.split(',')[0].trim() : req.protocol || 'http';
|
||||
const host = forwardedHost || req.headers.host || 'localhost';
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
function getPrimaryRoomCamera() {
|
||||
const cameras = getRoomCameras();
|
||||
if (!cameras.length) return null;
|
||||
return cameras[0];
|
||||
}
|
||||
|
||||
function sumQueueCounts(turnQueues = {}) {
|
||||
return Object.values(turnQueues).reduce((sum, entry) => {
|
||||
const size = Array.isArray(entry?.queue) ? entry.queue.length : 0;
|
||||
return sum + size;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getPublicRovers() {
|
||||
return roverManager
|
||||
.getRoster()
|
||||
.filter((rover) => roverManager.canReplayRoverId(rover.id));
|
||||
}
|
||||
|
||||
function buildEmbedCopy(state, camera) {
|
||||
const roversOnline = state?.rovers?.length || 0;
|
||||
const visibleRoverIds = new Set((state?.rovers || []).map((rover) => String(rover.id)));
|
||||
const driverCount = Object.entries(state?.activeDrivers || {}).reduce((count, [roverId, socketId]) => {
|
||||
if (!socketId) return count;
|
||||
return visibleRoverIds.has(String(roverId)) ? count + 1 : count;
|
||||
}, 0);
|
||||
const mode = state?.mode || 'open';
|
||||
const modeLabel = {
|
||||
open: 'open drive',
|
||||
turns: 'turns mode',
|
||||
admin: 'admin mode',
|
||||
lockdown: 'locked',
|
||||
}[mode] || mode;
|
||||
|
||||
let title = 'Multi Roomba Rover';
|
||||
if (mode === 'lockdown') {
|
||||
title = 'Private mode is on';
|
||||
} else if (roversOnline === 0) {
|
||||
title = 'Rovers offline - check back soon';
|
||||
} else if (driverCount > 0) {
|
||||
title = 'Rovers in use - drive a rover';
|
||||
} else if (mode === 'turns') {
|
||||
title = 'Controls open - jump in';
|
||||
} else {
|
||||
title = 'Controls open - drive a rover';
|
||||
}
|
||||
|
||||
const descriptionParts = [];
|
||||
descriptionParts.push(`${roversOnline} rover${roversOnline === 1 ? '' : 's'} online`);
|
||||
if (driverCount > 0) {
|
||||
descriptionParts.push(`${driverCount} driving`);
|
||||
} else {
|
||||
descriptionParts.push('no active drivers');
|
||||
}
|
||||
if (mode === 'lockdown') {
|
||||
descriptionParts.push('privacy mode');
|
||||
} else {
|
||||
descriptionParts.push(modeLabel);
|
||||
}
|
||||
const description = descriptionParts.join(' | ');
|
||||
|
||||
const statsParts = [
|
||||
`${roversOnline} online`,
|
||||
driverCount > 0 ? `${driverCount} driving` : 'no drivers',
|
||||
];
|
||||
if (mode === 'lockdown') {
|
||||
statsParts.push('privacy mode');
|
||||
} else {
|
||||
statsParts.push(modeLabel);
|
||||
}
|
||||
|
||||
const cameraLabel = camera?.name || camera?.id || 'room cam';
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
subtitle: 'Control a live rover from your browser',
|
||||
stats: statsParts.join(' | '),
|
||||
cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetaTags({ title, description, imageUrl, pageUrl }) {
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeDescription = escapeHtml(description);
|
||||
const safeImage = escapeHtml(imageUrl);
|
||||
const safeUrl = escapeHtml(pageUrl);
|
||||
return [
|
||||
'<!-- embed meta -->',
|
||||
`<meta name="description" content="${safeDescription}" />`,
|
||||
`<meta property="og:title" content="${safeTitle}" />`,
|
||||
`<meta property="og:description" content="${safeDescription}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
`<meta property="og:url" content="${safeUrl}" />`,
|
||||
`<meta property="og:image" content="${safeImage}" />`,
|
||||
'<meta property="og:image:width" content="1200" />',
|
||||
'<meta property="og:image:height" content="630" />',
|
||||
`<meta property="og:site_name" content="${safeTitle}" />`,
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${safeTitle}" />`,
|
||||
`<meta name="twitter:description" content="${safeDescription}" />`,
|
||||
`<meta name="twitter:image" content="${safeImage}" />`,
|
||||
'<!-- /embed meta -->',
|
||||
].join('\n ');
|
||||
}
|
||||
|
||||
async function renderIndexHtml(req) {
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const state = {
|
||||
mode: getMode(),
|
||||
rovers: getPublicRovers(),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
};
|
||||
const config = loadConfig();
|
||||
const pageTitle = config?.site?.title || 'Roomba Rover';
|
||||
const camera = getPrimaryRoomCamera();
|
||||
const copy = buildEmbedCopy(state, camera);
|
||||
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
|
||||
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
|
||||
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`;
|
||||
|
||||
const metaBlock = buildMetaTags({
|
||||
title: pageTitle,
|
||||
description: copy.description,
|
||||
imageUrl,
|
||||
pageUrl,
|
||||
});
|
||||
|
||||
let html = await loadIndexHtml();
|
||||
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`);
|
||||
if (html.includes('<!-- embed meta -->')) {
|
||||
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
|
||||
} else {
|
||||
html = html.replace('</head>', ` ${metaBlock}\n </head>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
|
||||
const titleSize = 64;
|
||||
const subtitleSize = 34;
|
||||
const statsSize = 30;
|
||||
const labelSize = 26;
|
||||
const badgeText = hasFrame ? 'Room cam live' : 'Room cam';
|
||||
return `
|
||||
<svg width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(0,0,0,0)" />
|
||||
<stop offset="50%" stop-color="rgba(0,0,0,0.35)" />
|
||||
<stop offset="100%" stop-color="rgba(0,0,0,0.85)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
|
||||
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
|
||||
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" />
|
||||
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018">
|
||||
${escapeXml(badgeText)}
|
||||
</text>
|
||||
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
|
||||
${escapeXml(title)}
|
||||
</text>
|
||||
<text x="64" y="455" font-family="DejaVu Sans, Arial, sans-serif" font-size="${subtitleSize}" font-weight="500" fill="#d6e4ff">
|
||||
${escapeXml(subtitle)}
|
||||
</text>
|
||||
<text x="64" y="505" font-family="DejaVu Sans, Arial, sans-serif" font-size="${statsSize}" font-weight="600" fill="#7ef9b2">
|
||||
${escapeXml(stats)}
|
||||
</text>
|
||||
<text x="64" y="552" font-family="DejaVu Sans, Arial, sans-serif" font-size="${labelSize}" font-weight="500" fill="#a7b6d8">
|
||||
${escapeXml(cameraLabel)}
|
||||
</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
async function renderOgImage() {
|
||||
const state = {
|
||||
mode: getMode(),
|
||||
rovers: getPublicRovers(),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
};
|
||||
const camera = getPrimaryRoomCamera();
|
||||
const copy = buildEmbedCopy(state, camera);
|
||||
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
|
||||
const frame = cameraState?.frame || null;
|
||||
const hasFrame = Boolean(frame);
|
||||
|
||||
const base = frame
|
||||
? sharp(frame).resize(OG_WIDTH, OG_HEIGHT, { fit: 'cover' })
|
||||
: sharp({
|
||||
create: {
|
||||
width: OG_WIDTH,
|
||||
height: OG_HEIGHT,
|
||||
channels: 3,
|
||||
background: BASE_BG,
|
||||
},
|
||||
});
|
||||
|
||||
const overlaySvg = Buffer.from(
|
||||
buildOverlaySvg({
|
||||
title: copy.title,
|
||||
subtitle: copy.subtitle,
|
||||
stats: copy.stats,
|
||||
cameraLabel: copy.cameraLabel,
|
||||
hasFrame,
|
||||
}),
|
||||
);
|
||||
|
||||
const composite = [{ input: overlaySvg, top: 0, left: 0 }];
|
||||
try {
|
||||
const logo = await fsp.readFile(BITMAP_PATH);
|
||||
const logoPng = await sharp(logo).resize(88, 88).png().toBuffer();
|
||||
composite.push({ input: logoPng, top: 42, left: OG_WIDTH - 130 });
|
||||
} catch (err) {
|
||||
// Optional logo; ignore if missing.
|
||||
}
|
||||
|
||||
return base.composite(composite).png().toBuffer();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderIndexHtml,
|
||||
renderOgImage,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
const EventEmitter = require('events');
|
||||
const baseLogger = require('../globals/logger');
|
||||
const logger = baseLogger.child ? baseLogger.child('eventBus') : baseLogger;
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
/**
|
||||
* Publish a structured event onto the server-wide bus.
|
||||
* @param {object} options
|
||||
* @param {string} options.source - Origin module identifier.
|
||||
* @param {string} options.type - Event type name.
|
||||
* @param {any} [options.payload] - Optional event payload.
|
||||
*/
|
||||
function publishEvent({ source, type, payload = null }) {
|
||||
if (!source) {
|
||||
throw new Error('eventBus.publishEvent requires source');
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('eventBus.publishEvent requires type');
|
||||
}
|
||||
const event = {
|
||||
source,
|
||||
type,
|
||||
payload,
|
||||
ts: Date.now(),
|
||||
};
|
||||
const logFn = typeof logger.debug === 'function' ? 'debug' : 'info';
|
||||
logger[logFn](`Publishing event ${type}`, { source });
|
||||
eventBus.emit(type, event);
|
||||
eventBus.emit('*', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events of a given type.
|
||||
* @param {string} type
|
||||
* @param {(event: object) => void} handler
|
||||
*/
|
||||
function subscribe(type, handler) {
|
||||
eventBus.on(type, handler);
|
||||
return () => eventBus.off(type, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all events on the bus.
|
||||
* @param {(event: object) => void} handler
|
||||
*/
|
||||
function subscribeAll(handler) {
|
||||
eventBus.on('*', handler);
|
||||
return () => eventBus.off('*', handler);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
eventBus,
|
||||
publishEvent,
|
||||
subscribe,
|
||||
subscribeAll,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getRoomCameras } = require('./roomCameraService');
|
||||
const { getRoomCameraState } = require('./roomCameraSnapshotService');
|
||||
const { getReplaySources } = require('./replaySourceService');
|
||||
const { replaySegmentsDir, segmentSeconds, bufferSeconds } = require('./replaySegmentManager');
|
||||
|
||||
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||
const HEALTH_INTERVAL_MS = 5000;
|
||||
const ROOM_CAMERA_STALE_MS = 5000;
|
||||
const ROVER_SNAPSHOT_STALE_MS = 5000;
|
||||
|
||||
let latest = {
|
||||
updatedAt: Date.now(),
|
||||
replay: { sources: [], readyCount: 0, totalCount: 0 },
|
||||
snapshots: { rovers: [], rooms: [] },
|
||||
};
|
||||
|
||||
async function collectReplayHealth(now) {
|
||||
const neededCount = Math.max(1, Math.ceil(20000 / (segmentSeconds * 1000)));
|
||||
const sources = getReplaySources();
|
||||
const list = [];
|
||||
let readyCount = 0;
|
||||
for (const source of sources) {
|
||||
const key = `${source.type}__${source.id}`;
|
||||
const dir = path.join(replaySegmentsDir, key);
|
||||
let lastSegmentAt = null;
|
||||
let recentCount = 0;
|
||||
try {
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
|
||||
const stat = await fsp.stat(path.join(dir, entry.name));
|
||||
if (stat.mtimeMs > (lastSegmentAt || 0)) {
|
||||
lastSegmentAt = stat.mtimeMs;
|
||||
}
|
||||
if (now - stat.mtimeMs <= bufferSeconds * 1000) {
|
||||
recentCount += 1;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// directory missing or unreadable
|
||||
}
|
||||
const ready = recentCount >= neededCount;
|
||||
if (ready) readyCount += 1;
|
||||
list.push({
|
||||
type: source.type,
|
||||
id: source.id,
|
||||
label: source.label || `${source.type}:${source.id}`,
|
||||
recentCount,
|
||||
neededCount,
|
||||
lastSegmentAt,
|
||||
ready,
|
||||
});
|
||||
}
|
||||
return { sources: list, readyCount, totalCount: list.length };
|
||||
}
|
||||
|
||||
async function collectSnapshotHealth(now) {
|
||||
const rovers = roverManager.getRoster().map((rover) => ({
|
||||
id: String(rover.id),
|
||||
name: rover.name || rover.id,
|
||||
}));
|
||||
const roverSnapshots = [];
|
||||
for (const rover of rovers) {
|
||||
const filePath = path.join(ROVER_SNAPSHOT_DIR, `${rover.id}.jpg`);
|
||||
let exists = false;
|
||||
let size = 0;
|
||||
let updatedAt = null;
|
||||
try {
|
||||
const stat = await fsp.stat(filePath);
|
||||
exists = true;
|
||||
size = stat.size;
|
||||
updatedAt = stat.mtimeMs;
|
||||
} catch {
|
||||
// missing snapshot
|
||||
}
|
||||
const ageMs = updatedAt ? now - updatedAt : null;
|
||||
const stale = ageMs != null ? ageMs > ROVER_SNAPSHOT_STALE_MS : true;
|
||||
roverSnapshots.push({
|
||||
id: rover.id,
|
||||
name: rover.name,
|
||||
exists,
|
||||
size,
|
||||
updatedAt,
|
||||
ageMs,
|
||||
stale,
|
||||
});
|
||||
}
|
||||
|
||||
const roomSnapshots = getRoomCameras().map((camera) => {
|
||||
const state = getRoomCameraState(camera.id);
|
||||
const updatedAt = state?.ts || null;
|
||||
const ageMs = updatedAt ? now - updatedAt : null;
|
||||
const stale = ageMs != null ? ageMs > ROOM_CAMERA_STALE_MS : true;
|
||||
return {
|
||||
id: camera.id,
|
||||
name: camera.name || camera.id,
|
||||
updatedAt,
|
||||
ageMs,
|
||||
error: state?.error || null,
|
||||
stale,
|
||||
};
|
||||
});
|
||||
|
||||
return { rovers: roverSnapshots, rooms: roomSnapshots };
|
||||
}
|
||||
|
||||
async function refreshHealth() {
|
||||
const now = Date.now();
|
||||
const replay = await collectReplayHealth(now);
|
||||
const snapshots = await collectSnapshotHealth(now);
|
||||
latest = { updatedAt: now, replay, snapshots };
|
||||
}
|
||||
|
||||
refreshHealth();
|
||||
setInterval(refreshHealth, HEALTH_INTERVAL_MS);
|
||||
|
||||
function getHealthSnapshot() {
|
||||
return latest;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHealthSnapshot,
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
const EventEmitter = require('events');
|
||||
const WebSocket = require('ws');
|
||||
const { createConnection, subscribeEntities, callService, Auth } = require('home-assistant-js-websocket');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('homeAssistantService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { getMode } = require('./modeManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
|
||||
// home-assistant-js-websocket expects a global WebSocket in Node.
|
||||
if (!global.WebSocket) {
|
||||
global.WebSocket = WebSocket;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
|
||||
const events = new EventEmitter();
|
||||
const entityConfig = new Map(); // entityId -> { id, name, type }
|
||||
const entityState = new Map(); // entityId -> normalized state
|
||||
|
||||
let connection = null;
|
||||
let unsubscribeEntities = null;
|
||||
let reconnectTimer = null;
|
||||
let connected = false;
|
||||
|
||||
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
||||
|
||||
function buildAuth() {
|
||||
const token = haConfig?.token?.trim();
|
||||
const url = haConfig?.url?.trim();
|
||||
if (!token || !url) {
|
||||
throw new Error('Home Assistant url/token missing');
|
||||
}
|
||||
// Long-lived tokens last 10 years; set a far future expiry to avoid refresh attempts.
|
||||
return new Auth(
|
||||
{
|
||||
hassUrl: url.replace(/\/+$/, ''),
|
||||
access_token: token,
|
||||
expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10,
|
||||
refresh_token: null,
|
||||
clientId: 'multiroomba-rover',
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function inferType(entityId, explicitType) {
|
||||
if (explicitType === 'light' || explicitType === 'switch') {
|
||||
return explicitType;
|
||||
}
|
||||
const domain = String(entityId || '').split('.')[0];
|
||||
if (domain === 'light') return 'light';
|
||||
return 'switch';
|
||||
}
|
||||
|
||||
function normalizeConfigEntry(entry) {
|
||||
if (!entry) return null;
|
||||
const id = entry.id || entry.entityId || entry.entity_id;
|
||||
if (!id) return null;
|
||||
const type = inferType(id, entry.type);
|
||||
const name = entry.name || null;
|
||||
return { id: String(id), name, type };
|
||||
}
|
||||
|
||||
function loadEntityConfig() {
|
||||
entityConfig.clear();
|
||||
const list = Array.isArray(haConfig?.entities) ? haConfig.entities : [];
|
||||
list.forEach((entry) => {
|
||||
const normalized = normalizeConfigEntry(entry);
|
||||
if (normalized) {
|
||||
entityConfig.set(normalized.id, normalized);
|
||||
if (!entityState.has(normalized.id)) {
|
||||
entityState.set(normalized.id, buildState(normalized, null));
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
|
||||
}
|
||||
|
||||
function buildState(meta, raw) {
|
||||
if (!meta) return null;
|
||||
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
|
||||
const supportedColorModes = Array.isArray(raw?.attributes?.supported_color_modes)
|
||||
? raw.attributes.supported_color_modes.map((mode) => String(mode))
|
||||
: [];
|
||||
const rgbColor = Array.isArray(raw?.attributes?.rgb_color) ? raw.attributes.rgb_color : null;
|
||||
const hsColor = Array.isArray(raw?.attributes?.hs_color) ? raw.attributes.hs_color : null;
|
||||
const supportsColor =
|
||||
meta.type === 'light' &&
|
||||
(rgbColor ||
|
||||
hsColor ||
|
||||
supportedColorModes.some((mode) => mode === 'hs' || mode === 'rgb' || mode === 'xy'));
|
||||
if (!raw) {
|
||||
return {
|
||||
id: meta.id,
|
||||
name,
|
||||
type: meta.type,
|
||||
state: 'unknown',
|
||||
available: false,
|
||||
lastChanged: null,
|
||||
lastUpdated: null,
|
||||
supportedColorModes,
|
||||
colorMode: null,
|
||||
rgbColor: null,
|
||||
hsColor: null,
|
||||
supportsColor,
|
||||
};
|
||||
}
|
||||
const rawState = raw.state;
|
||||
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
|
||||
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
|
||||
return {
|
||||
id: meta.id,
|
||||
name,
|
||||
type: meta.type,
|
||||
state,
|
||||
available: !unavailable,
|
||||
lastChanged: raw.last_changed || null,
|
||||
lastUpdated: raw.last_updated || null,
|
||||
supportedColorModes,
|
||||
colorMode: raw?.attributes?.color_mode || null,
|
||||
rgbColor,
|
||||
hsColor,
|
||||
supportsColor,
|
||||
};
|
||||
}
|
||||
|
||||
function emitUpdate() {
|
||||
events.emit('update', getState());
|
||||
}
|
||||
|
||||
function emitStatus() {
|
||||
events.emit('status', getState());
|
||||
}
|
||||
|
||||
function handleEntitySnapshot(snapshot = {}) {
|
||||
let changed = false;
|
||||
entityConfig.forEach((meta, id) => {
|
||||
const raw = snapshot[id];
|
||||
const next = buildState(meta, raw);
|
||||
const prev = entityState.get(id);
|
||||
if (
|
||||
!prev ||
|
||||
prev.state !== next.state ||
|
||||
prev.available !== next.available ||
|
||||
prev.lastChanged !== next.lastChanged
|
||||
) {
|
||||
entityState.set(id, next);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
emitUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function teardownConnection() {
|
||||
if (unsubscribeEntities) {
|
||||
try {
|
||||
unsubscribeEntities();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to unsubscribe entity stream', err.message);
|
||||
}
|
||||
}
|
||||
unsubscribeEntities = null;
|
||||
if (connection) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (err) {
|
||||
logger.warn('Error closing Home Assistant connection', err.message);
|
||||
}
|
||||
}
|
||||
connection = null;
|
||||
const wasConnected = connected;
|
||||
connected = false;
|
||||
if (wasConnected) {
|
||||
emitStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(delayMs = 5000) {
|
||||
if (!enabled) return;
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!enabled) {
|
||||
logger.info('Home Assistant integration disabled; missing url/token in config');
|
||||
return;
|
||||
}
|
||||
if (connection) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const auth = buildAuth();
|
||||
connection = await createConnection({ auth, setupRetry: 0 });
|
||||
connected = true;
|
||||
emitStatus();
|
||||
logger.info('Connected to Home Assistant');
|
||||
unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot);
|
||||
connection.addEventListener('disconnected', () => {
|
||||
logger.warn('Home Assistant connection lost');
|
||||
teardownConnection();
|
||||
scheduleReconnect();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Home Assistant connection failed', err.message);
|
||||
teardownConnection();
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function setEntityState(entityId, desiredState) {
|
||||
if (!enabled) {
|
||||
throw new Error('Home Assistant not configured');
|
||||
}
|
||||
const meta = entityConfig.get(entityId);
|
||||
if (!meta) {
|
||||
throw new Error('Unknown Home Assistant entity');
|
||||
}
|
||||
if (!connection) {
|
||||
throw new Error('Home Assistant not connected');
|
||||
}
|
||||
const nextState = desiredState === 'on' ? 'on' : 'off';
|
||||
const domain = meta.type === 'light' ? 'light' : 'switch';
|
||||
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
||||
await callService(connection, domain, service, { entity_id: entityId });
|
||||
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
||||
}
|
||||
|
||||
async function toggleEntity(entityId) {
|
||||
const current = entityState.get(entityId);
|
||||
const nextState = current?.state === 'on' ? 'off' : 'on';
|
||||
return setEntityState(entityId, nextState);
|
||||
}
|
||||
|
||||
async function setLightColor(entityId, rgbColor) {
|
||||
if (!enabled) {
|
||||
throw new Error('Home Assistant not configured');
|
||||
}
|
||||
const meta = entityConfig.get(entityId);
|
||||
if (!meta || meta.type !== 'light') {
|
||||
throw new Error('Home Assistant light required');
|
||||
}
|
||||
if (!connection) {
|
||||
throw new Error('Home Assistant not connected');
|
||||
}
|
||||
if (!Array.isArray(rgbColor) || rgbColor.length !== 3) {
|
||||
throw new Error('rgbColor required');
|
||||
}
|
||||
const normalized = rgbColor.map((value) => {
|
||||
const next = Number(value);
|
||||
if (Number.isNaN(next)) return 0;
|
||||
return Math.max(0, Math.min(255, Math.round(next)));
|
||||
});
|
||||
await callService(connection, 'light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
|
||||
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
||||
}
|
||||
|
||||
function getState() {
|
||||
const entities = Array.from(entityConfig.values()).map(
|
||||
(meta) => entityState.get(meta.id) || buildState(meta, null),
|
||||
);
|
||||
return { enabled, connected, entities };
|
||||
}
|
||||
|
||||
loadEntityConfig();
|
||||
connect();
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
||||
const mode = getMode();
|
||||
if (
|
||||
(mode === 'admin' && isAdmin(socket) !== true) ||
|
||||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
||||
) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
await toggleEntity(entityId);
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
|
||||
const mode = getMode();
|
||||
if (
|
||||
(mode === 'admin' && isAdmin(socket) !== true) ||
|
||||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
||||
) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
await setEntityState(entityId, state);
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('homeAssistant:lightColor', async ({ entityId, rgbColor } = {}, cb = () => {}) => {
|
||||
const mode = getMode();
|
||||
if (
|
||||
(mode === 'admin' && isAdmin(socket) !== true) ||
|
||||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
||||
) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
await setLightColor(entityId, rgbColor);
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
toggleEntity,
|
||||
setEntityState,
|
||||
setLightColor,
|
||||
homeAssistantEvents: events,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,17 +22,6 @@ function enforceLockdown() {
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
socket.data.lockdownTimer = setTimeout(() => {
|
||||
if (!isLockdownAdmin(socket)) {
|
||||
disconnectForLockdown(socket);
|
||||
}
|
||||
}, 10000);
|
||||
socket.once('disconnect', () => clearLockdownTimer(socket));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
enforceLockdown,
|
||||
disconnectForLockdown,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const { sendAlert, COLORS } = require('./alertService');
|
||||
const { sendAlert } = require('./alertService');
|
||||
const ALERT_COLOR = '#ff9800';
|
||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
|
||||
const MODES = {
|
||||
OPEN: 'open',
|
||||
@@ -10,21 +12,25 @@ const MODES = {
|
||||
LOCKDOWN: 'lockdown',
|
||||
};
|
||||
|
||||
let currentMode = MODES.OPEN;
|
||||
let currentMode = MODES.ADMIN;
|
||||
const modeEvents = new EventEmitter();
|
||||
|
||||
function canChangeMode(socket, nextMode) {
|
||||
if (nextMode === MODES.LOCKDOWN) {
|
||||
if (currentMode === MODES.LOCKDOWN && nextMode !== MODES.LOCKDOWN) {
|
||||
return isLockdownAdmin(socket);
|
||||
}
|
||||
if (nextMode === MODES.LOCKDOWN) {
|
||||
return isAdmin(socket);
|
||||
}
|
||||
return isAdmin(socket);
|
||||
}
|
||||
|
||||
function setMode(nextMode, socket) {
|
||||
function setMode(nextMode, socket, options = {}) {
|
||||
if (!Object.values(MODES).includes(nextMode)) {
|
||||
throw new Error(`Unknown mode ${nextMode}`);
|
||||
}
|
||||
if (!canChangeMode(socket, nextMode)) {
|
||||
const force = Boolean(options.force);
|
||||
if (!force && !canChangeMode(socket, nextMode)) {
|
||||
throw new Error('Not authorized to change mode');
|
||||
}
|
||||
if (currentMode === nextMode) {
|
||||
@@ -32,10 +38,15 @@ function setMode(nextMode, socket) {
|
||||
}
|
||||
currentMode = nextMode;
|
||||
sendAlert({
|
||||
color: COLORS.info,
|
||||
color: ALERT_COLOR,
|
||||
title: 'Mode Changed',
|
||||
message: `Server mode set to ${nextMode}`,
|
||||
});
|
||||
publishEvent({
|
||||
source: 'modeManager',
|
||||
type: 'mode.changed',
|
||||
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
|
||||
});
|
||||
modeEvents.emit('change', currentMode);
|
||||
io.emit('mode', { mode: currentMode });
|
||||
return currentMode;
|
||||
@@ -58,7 +69,7 @@ io.on('connection', (socket) => {
|
||||
try {
|
||||
setMode(mode, socket);
|
||||
} catch (err) {
|
||||
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message });
|
||||
sendAlert({ color: ALERT_COLOR, title: 'Mode change failed', message: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,50 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('nicknameService');
|
||||
|
||||
const nicknameEvents = new EventEmitter();
|
||||
|
||||
function sanitizeNickname(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
const sanitized = trimmed.replace(/\*/g, 'nope');
|
||||
return sanitized.slice(0, 32);
|
||||
}
|
||||
|
||||
function getNickname(socket) {
|
||||
return socket?.data?.nickname || '';
|
||||
}
|
||||
|
||||
function setNickname(socket, nickname) {
|
||||
if (!socket) return null;
|
||||
const value = sanitizeNickname(nickname);
|
||||
if (!value) {
|
||||
throw new Error('Nickname required');
|
||||
}
|
||||
socket.data = socket.data || {};
|
||||
if (socket.data.nickname === value) {
|
||||
return value;
|
||||
}
|
||||
socket.data.nickname = value;
|
||||
nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
|
||||
logger.info('Nickname set', { socketId: socket.id, nickname: value });
|
||||
return value;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('nickname:set', ({ nickname } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const value = setNickname(socket, nickname);
|
||||
cb({ success: true, nickname: value });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getNickname,
|
||||
setNickname,
|
||||
nicknameEvents,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
const { execFile } = require('child_process');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
const logger = require('../globals/logger').child('replayBuild');
|
||||
const { replaySegmentsDir, segmentSeconds } = require('./replaySegmentManager');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const REPLAY_DURATION_MS = 20000;
|
||||
const REPLAY_FPS = 15;
|
||||
const MAX_REPLAY_WIDTH = 1280;
|
||||
const MAX_REPLAY_HEIGHT = 720;
|
||||
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
|
||||
|
||||
function buildGridLayout(count) {
|
||||
const cols = Math.ceil(Math.sqrt(count));
|
||||
const rows = Math.ceil(count / cols);
|
||||
return { cols, rows };
|
||||
}
|
||||
|
||||
function clampEven(value) {
|
||||
return Math.max(2, Math.floor(value / 2) * 2);
|
||||
}
|
||||
|
||||
function buildScalePadFilter(tileWidth, tileHeight) {
|
||||
return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
|
||||
}
|
||||
|
||||
async function probeMaxFrameSize(paths) {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
'v:0',
|
||||
'-show_entries',
|
||||
'stream=width,height',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
filePath,
|
||||
]);
|
||||
const [widthRaw, heightRaw] = stdout.trim().split(',');
|
||||
const width = Number(widthRaw);
|
||||
const height = Number(heightRaw);
|
||||
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to probe replay clip size', err.message);
|
||||
}
|
||||
}
|
||||
return { maxWidth, maxHeight };
|
||||
}
|
||||
|
||||
async function listLatestSegments(sourceKey, neededCount) {
|
||||
const dir = path.join(replaySegmentsDir, sourceKey);
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
const now = Date.now();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (stat.size < 16 * 1024) {
|
||||
continue;
|
||||
}
|
||||
if (now - stat.mtimeMs < segmentSeconds * 1000) {
|
||||
continue;
|
||||
}
|
||||
files.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
return files.slice(-neededCount).map((file) => file.filePath);
|
||||
}
|
||||
|
||||
async function buildReplayVideo({ sources = [] } = {}) {
|
||||
if (!sources.length) {
|
||||
throw new Error('No replay sources selected');
|
||||
}
|
||||
const segmentCount = Math.max(1, Math.ceil(REPLAY_DURATION_MS / (segmentSeconds * 1000)));
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||
try {
|
||||
const clipPaths = [];
|
||||
const usedSources = [];
|
||||
const missingSources = [];
|
||||
for (let i = 0; i < sources.length; i += 1) {
|
||||
const source = sources[i];
|
||||
const key = `${source.type}__${source.id}`;
|
||||
let segmentPaths;
|
||||
try {
|
||||
segmentPaths = await listLatestSegments(key, segmentCount);
|
||||
} catch (err) {
|
||||
missingSources.push({ ...source, reason: err.message || 'missing segments' });
|
||||
continue;
|
||||
}
|
||||
if (segmentPaths.length < segmentCount) {
|
||||
missingSources.push({
|
||||
...source,
|
||||
reason: `only ${segmentPaths.length}/${segmentCount} segments available`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const concatPath = path.join(tmpDir, `concat-${clipPaths.length}.txt`);
|
||||
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
|
||||
await fsp.writeFile(concatPath, concatBody);
|
||||
const clipPath = path.join(tmpDir, `clip-${clipPaths.length}.mp4`);
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'concat',
|
||||
'-safe',
|
||||
'0',
|
||||
'-i',
|
||||
concatPath,
|
||||
'-c',
|
||||
'copy',
|
||||
clipPath,
|
||||
]);
|
||||
clipPaths.push(clipPath);
|
||||
usedSources.push(source);
|
||||
}
|
||||
if (!clipPaths.length) {
|
||||
throw new Error('No replay segments available for selected sources');
|
||||
}
|
||||
if (missingSources.length) {
|
||||
logger.warn('Replay sources missing segments', {
|
||||
missing: missingSources.map((source) => `${source.type}:${source.id}`),
|
||||
});
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
|
||||
const layout = buildGridLayout(clipPaths.length);
|
||||
let tileWidth = maxWidth || 640;
|
||||
let tileHeight = maxHeight || 360;
|
||||
let outputWidth = tileWidth * layout.cols;
|
||||
let outputHeight = tileHeight * layout.rows;
|
||||
if (outputWidth > MAX_REPLAY_WIDTH || outputHeight > MAX_REPLAY_HEIGHT) {
|
||||
const scale = Math.min(MAX_REPLAY_WIDTH / outputWidth, MAX_REPLAY_HEIGHT / outputHeight);
|
||||
tileWidth *= scale;
|
||||
tileHeight *= scale;
|
||||
outputWidth = tileWidth * layout.cols;
|
||||
outputHeight = tileHeight * layout.rows;
|
||||
}
|
||||
tileWidth = clampEven(tileWidth);
|
||||
tileHeight = clampEven(tileHeight);
|
||||
outputWidth = clampEven(tileWidth * layout.cols);
|
||||
outputHeight = clampEven(tileHeight * layout.rows);
|
||||
|
||||
const inputArgs = [];
|
||||
const filterParts = [];
|
||||
const layoutParts = [];
|
||||
for (let i = 0; i < clipPaths.length; i += 1) {
|
||||
inputArgs.push('-i', clipPaths[i]);
|
||||
filterParts.push(`[${i}:v]${buildScalePadFilter(tileWidth, tileHeight)}[v${i}]`);
|
||||
const x = (i % layout.cols) * tileWidth;
|
||||
const y = Math.floor(i / layout.cols) * tileHeight;
|
||||
layoutParts.push(`${x}_${y}`);
|
||||
}
|
||||
if (clipPaths.length === 1) {
|
||||
filterParts.push('[v0]null[v]');
|
||||
} else {
|
||||
filterParts.push(
|
||||
`${clipPaths.map((_, i) => `[v${i}]`).join('')}` +
|
||||
`xstack=inputs=${clipPaths.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
|
||||
);
|
||||
}
|
||||
|
||||
const durationSec = Math.max(1, REPLAY_DURATION_MS / 1000);
|
||||
const targetBitrateKbps = Math.max(300, Math.floor((REPLAY_MAX_BYTES * 8) / durationSec / 1000));
|
||||
const maxrateKbps = Math.floor(targetBitrateKbps * 1.1);
|
||||
const bufsizeKbps = Math.floor(targetBitrateKbps * 2);
|
||||
|
||||
const outPath = path.join(tmpDir, 'replay.mp4');
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
...inputArgs,
|
||||
'-filter_complex',
|
||||
filterParts.join(';'),
|
||||
'-map',
|
||||
'[v]',
|
||||
'-r',
|
||||
String(REPLAY_FPS),
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-b:v',
|
||||
`${targetBitrateKbps}k`,
|
||||
'-maxrate',
|
||||
`${maxrateKbps}k`,
|
||||
'-bufsize',
|
||||
`${bufsizeKbps}k`,
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
outPath,
|
||||
]);
|
||||
const buffer = await fsp.readFile(outPath);
|
||||
return { buffer, usedSources, missingSources };
|
||||
} finally {
|
||||
try {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to cleanup replay temp dir', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildReplayVideo,
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
const { spawn } = require('child_process');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const logger = require('../globals/logger').child('replaySegments');
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
const SEGMENT_DIR = process.env.REPLAY_SEGMENT_DIR || '/var/lib/replay-segments';
|
||||
const SEGMENT_SECONDS = 2;
|
||||
const BUFFER_SECONDS = 40;
|
||||
const CLEANUP_INTERVAL_MS = 20000;
|
||||
const FPS = 15;
|
||||
const SCALE_WIDTH = 640;
|
||||
const MAX_BYTES = Number.parseInt(process.env.REPLAY_SEGMENT_MAX_BYTES || '0', 10);
|
||||
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||
const ROVER_SNAPSHOT_FPS = 3;
|
||||
|
||||
const recorders = new Map(); // key -> { proc, source }
|
||||
let cleanupTimer = null;
|
||||
|
||||
function sourceKey(source) {
|
||||
return `${source.type}__${source.id}`;
|
||||
}
|
||||
|
||||
function getRoomCameraStream(camera) {
|
||||
if (camera.streamUrl) return camera.streamUrl;
|
||||
const url = String(camera.url || '');
|
||||
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listSources() {
|
||||
const rooms = getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
streamUrl: getRoomCameraStream(camera),
|
||||
}));
|
||||
const rovers = roverManager.getRoster().map((rover) => ({
|
||||
type: 'rover',
|
||||
id: String(rover.id),
|
||||
label: rover.name || rover.id,
|
||||
}));
|
||||
return [...rooms, ...rovers];
|
||||
}
|
||||
|
||||
function buildInputUrl(source) {
|
||||
if (source.type === 'room') {
|
||||
return source.streamUrl || null;
|
||||
}
|
||||
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(source.id)}`;
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function buildOutputPattern(key) {
|
||||
return path.join(SEGMENT_DIR, key, '%Y%m%d-%H%M%S.mp4');
|
||||
}
|
||||
|
||||
function spawnRecorder(source) {
|
||||
const key = sourceKey(source);
|
||||
const inputUrl = buildInputUrl(source);
|
||||
if (!inputUrl) {
|
||||
logger.warn('Replay recorder missing input URL', { key, source });
|
||||
return;
|
||||
}
|
||||
const outPattern = buildOutputPattern(key);
|
||||
const outputSnapshot = source.type === 'rover'
|
||||
? path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`)
|
||||
: null;
|
||||
ensureDir(path.dirname(outPattern))
|
||||
.then(async () => {
|
||||
if (outputSnapshot) {
|
||||
await ensureDir(ROVER_SNAPSHOT_DIR);
|
||||
}
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'info',
|
||||
'-y',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flags',
|
||||
'low_delay',
|
||||
'-i',
|
||||
inputUrl,
|
||||
];
|
||||
if (outputSnapshot) {
|
||||
args.push(
|
||||
'-filter_complex',
|
||||
`[0:v]fps=${FPS},scale=${SCALE_WIDTH}:-1[vseg];` +
|
||||
`[0:v]fps=${ROVER_SNAPSHOT_FPS},scale=${SCALE_WIDTH}:-1[vjpg]`,
|
||||
'-map',
|
||||
'[vseg]',
|
||||
'-an',
|
||||
'-c:v:0',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'veryfast',
|
||||
'-tune',
|
||||
'zerolatency',
|
||||
'-g',
|
||||
String(FPS),
|
||||
'-keyint_min',
|
||||
String(FPS),
|
||||
'-sc_threshold',
|
||||
'0',
|
||||
'-f',
|
||||
'segment',
|
||||
'-segment_time',
|
||||
String(SEGMENT_SECONDS),
|
||||
'-segment_format',
|
||||
'mp4',
|
||||
'-reset_timestamps',
|
||||
'1',
|
||||
'-strftime',
|
||||
'1',
|
||||
outPattern,
|
||||
'-map',
|
||||
'[vjpg]',
|
||||
'-an',
|
||||
'-q:v:1',
|
||||
'8',
|
||||
'-f',
|
||||
'image2',
|
||||
'-update',
|
||||
'1',
|
||||
outputSnapshot,
|
||||
);
|
||||
} else {
|
||||
args.push(
|
||||
'-r',
|
||||
String(FPS),
|
||||
'-vf',
|
||||
`fps=${FPS},scale=${SCALE_WIDTH}:-1`,
|
||||
'-an',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'veryfast',
|
||||
'-tune',
|
||||
'zerolatency',
|
||||
'-g',
|
||||
String(FPS),
|
||||
'-keyint_min',
|
||||
String(FPS),
|
||||
'-sc_threshold',
|
||||
'0',
|
||||
'-f',
|
||||
'segment',
|
||||
'-segment_time',
|
||||
String(SEGMENT_SECONDS),
|
||||
'-segment_format',
|
||||
'mp4',
|
||||
'-reset_timestamps',
|
||||
'1',
|
||||
'-strftime',
|
||||
'1',
|
||||
outPattern,
|
||||
);
|
||||
}
|
||||
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
const stderrChunks = [];
|
||||
let stderrSize = 0;
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
if (!chunk || stderrSize > 8192) return;
|
||||
stderrChunks.push(chunk);
|
||||
stderrSize += chunk.length;
|
||||
});
|
||||
recorders.set(key, { proc, source });
|
||||
proc.on('exit', (code, signal) => {
|
||||
recorders.delete(key);
|
||||
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
|
||||
if (stderrBuffer && stderrBuffer.length) {
|
||||
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
|
||||
logger.warn('Replay recorder stderr', {
|
||||
key,
|
||||
stderrBytes: stderrBuffer.length,
|
||||
stderrPreview: preview || '<non-utf8>',
|
||||
});
|
||||
} else {
|
||||
logger.warn('Replay recorder stderr', { key, stderrBytes: 0 });
|
||||
}
|
||||
if (!shouldRecord(source)) {
|
||||
return;
|
||||
}
|
||||
const delay = 2000;
|
||||
logger.warn('Replay recorder exited; restarting', { key, code, signal });
|
||||
setTimeout(() => {
|
||||
if (!recorders.has(key) && shouldRecord(source)) {
|
||||
spawnRecorder(source);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('Replay recorder setup failed', { key, err: err.message });
|
||||
});
|
||||
}
|
||||
|
||||
function stopRecorder(key) {
|
||||
const entry = recorders.get(key);
|
||||
if (!entry) return;
|
||||
entry.proc.kill('SIGTERM');
|
||||
recorders.delete(key);
|
||||
}
|
||||
|
||||
async function removeSourceArtifacts(key, source) {
|
||||
try {
|
||||
await fsp.rm(path.join(SEGMENT_DIR, key), { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clear replay segments', { key, error: err.message });
|
||||
}
|
||||
if (source?.type === 'rover' && source?.id) {
|
||||
try {
|
||||
await fsp.rm(path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`), { force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clear rover snapshot artifact', { roverId: source.id, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRecord(source) {
|
||||
if (source.type === 'room') {
|
||||
return Boolean(source.streamUrl);
|
||||
}
|
||||
return roverManager.canReplayRoverId(source.id);
|
||||
}
|
||||
|
||||
function syncRecorders() {
|
||||
const sources = listSources();
|
||||
const desiredKeys = new Set();
|
||||
sources.forEach((source) => {
|
||||
if (!shouldRecord(source)) return;
|
||||
const key = sourceKey(source);
|
||||
desiredKeys.add(key);
|
||||
if (!recorders.has(key)) {
|
||||
spawnRecorder(source);
|
||||
}
|
||||
});
|
||||
Array.from(recorders.keys()).forEach((key) => {
|
||||
if (!desiredKeys.has(key)) {
|
||||
const entry = recorders.get(key);
|
||||
stopRecorder(key);
|
||||
removeSourceArtifacts(key, entry?.source).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupSegments() {
|
||||
try {
|
||||
await ensureDir(SEGMENT_DIR);
|
||||
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
||||
const entries = await fsp.readdir(SEGMENT_DIR, { withFileTypes: true });
|
||||
let totalBytes = 0;
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dirPath = path.join(SEGMENT_DIR, entry.name);
|
||||
const inner = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
for (const file of inner) {
|
||||
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
|
||||
const filePath = path.join(dirPath, file.name);
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalBytes += stats.size;
|
||||
files.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size });
|
||||
if (stats.mtimeMs < cutoff) {
|
||||
await fsp.unlink(filePath);
|
||||
}
|
||||
}
|
||||
const remaining = await fsp.readdir(dirPath);
|
||||
if (!remaining.length) {
|
||||
await fsp.rmdir(dirPath);
|
||||
}
|
||||
}
|
||||
if (MAX_BYTES > 0 && totalBytes > MAX_BYTES) {
|
||||
const overBy = totalBytes - MAX_BYTES;
|
||||
let freed = 0;
|
||||
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
for (const file of files) {
|
||||
if (freed >= overBy) break;
|
||||
try {
|
||||
await fsp.unlink(file.filePath);
|
||||
freed += file.size;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Replay cleanup failed', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
syncRecorders();
|
||||
cleanupSegments();
|
||||
if (cleanupTimer) clearInterval(cleanupTimer);
|
||||
cleanupTimer = setInterval(cleanupSegments, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
roomCameraEvents.on('update', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('private', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
start();
|
||||
|
||||
module.exports = {
|
||||
replaySegmentsDir: SEGMENT_DIR,
|
||||
segmentSeconds: SEGMENT_SECONDS,
|
||||
bufferSeconds: BUFFER_SECONDS,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user