mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Compare commits
927
Commits
| 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 | ||
|
|
a5d1cada5c | ||
|
|
9fa1656b6d | ||
|
|
fb04103593 | ||
|
|
922f0b1188 | ||
|
|
ce5cd909e1 | ||
|
|
d26587140d | ||
|
|
37dbb1c5d9 | ||
|
|
a6995e0789 | ||
|
|
e0f9f626b2 | ||
|
|
71b9723e7e | ||
|
|
51a8f7bdcf | ||
|
|
fcc99dd4b3 | ||
|
|
5c061d5103 | ||
|
|
ce8222bf76 | ||
|
|
ee46bcff9f | ||
|
|
2afab19ff5 | ||
|
|
9b80c2f4c6 | ||
|
|
750c6401d2 | ||
|
|
18022a2a91 | ||
|
|
e7767bacbf | ||
|
|
36bda59824 | ||
|
|
c35928b458 | ||
|
|
e8f03ab405 | ||
|
|
38c087b7d5 | ||
|
|
8c25114c50 | ||
|
|
57dc40dfd3 | ||
|
|
c55b5a7092 | ||
|
|
aa6808a400 | ||
|
|
f645390c92 | ||
|
|
e87938746c | ||
|
|
f27a5554f2 | ||
|
|
08011d7ea1 | ||
|
|
4acfe5cfaa | ||
|
|
938323d930 | ||
|
|
94a1ec1ce8 | ||
|
|
0caae506f4 | ||
|
|
d55726eb1f | ||
|
|
7a7a194fde | ||
|
|
edcb465630 | ||
|
|
392e2f661b | ||
|
|
6b52c84b86 | ||
|
|
e2a121c1da | ||
|
|
fc7644366b | ||
|
|
2e1186a482 | ||
|
|
6d4aacb903 | ||
|
|
e494b1027b | ||
|
|
c011a92267 | ||
|
|
d9d5f94ea0 | ||
|
|
a8090ce2fb | ||
|
|
e3a0161ba6 | ||
|
|
248fe3eef1 | ||
|
|
0de8fb9633 | ||
|
|
c9d5b4e0f3 | ||
|
|
eb2e2b15d8 | ||
|
|
50c56e7271 | ||
|
|
b242ecf5ac | ||
|
|
fc1e0268b2 | ||
|
|
d984951b20 | ||
|
|
49253a6207 | ||
|
|
b3f181ec45 | ||
|
|
85d0c9cfd3 | ||
|
|
d64cfe2d46 | ||
|
|
4d3bc09e2b | ||
|
|
093654ea89 | ||
|
|
577a277e7c | ||
|
|
d524c312cf | ||
|
|
6cfdde30ea | ||
|
|
835850e686 | ||
|
|
cd98e722b9 | ||
|
|
ed9c0966ef | ||
|
|
0166b6f9fb | ||
|
|
39b07ecfa0 | ||
|
|
0f9998ed16 | ||
|
|
29255fd80e | ||
|
|
930b48340d | ||
|
|
df0cf2a17b | ||
|
|
0d233677f6 | ||
|
|
6f6104792b | ||
|
|
62603643e8 | ||
|
|
879674238f | ||
|
|
73e58b213a | ||
|
|
bc88cafafb | ||
|
|
326ae16f6b | ||
|
|
2dc18da12d | ||
|
|
99f446679e | ||
|
|
f030376d35 | ||
|
|
2e92d928ae | ||
|
|
a35bdad805 | ||
|
|
ab3b49b13e | ||
|
|
d47ff3f60a | ||
|
|
ce99309b5d | ||
|
|
a90033e933 | ||
|
|
026ce2efac | ||
|
|
440325296c | ||
|
|
588c7aa571 | ||
|
|
c0dce561bd | ||
|
|
31e31bfe2d | ||
|
|
dc73336d25 | ||
|
|
5923e61d49 | ||
|
|
87ac9540fe | ||
|
|
1adaf6c5f6 | ||
|
|
7e7d644822 | ||
|
|
7195e257b8 | ||
|
|
fafc3e0312 | ||
|
|
2bc5baa156 | ||
|
|
a24e487c51 | ||
|
|
8c79c70520 | ||
|
|
d601e9aa82 | ||
|
|
4d5a918921 | ||
|
|
24e68e20cf | ||
|
|
73b1b44c35 | ||
|
|
b5ce6706d6 | ||
|
|
beea4453dd | ||
|
|
74a460a980 | ||
|
|
3bc2921f25 | ||
|
|
c89e9cdfd3 | ||
|
|
714929ef83 | ||
|
|
e472b0b2ac | ||
|
|
114a87ce60 | ||
|
|
f48ced67e4 | ||
|
|
7553d52c6a | ||
|
|
36f6329354 | ||
|
|
7476e2112b | ||
|
|
283298e5ff | ||
|
|
337435b128 | ||
|
|
50623236e5 | ||
|
|
c621f04d16 | ||
|
|
28cbd910dd | ||
|
|
3a91e0cff0 | ||
|
|
b6bc4674f4 | ||
|
|
f03a7a404a | ||
|
|
d8bdb5cbd1 | ||
|
|
f90083249f | ||
|
|
eb9538ec7b | ||
|
|
bfe642c3ed | ||
|
|
5b1a7f5271 | ||
|
|
b246c08217 | ||
|
|
a8e0d1416e | ||
|
|
6162678e02 |
+13
-3
@@ -3,7 +3,17 @@ create_2_Open_Interface_Spec.txt
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
logs
|
||||||
.node_modules
|
node_modules/
|
||||||
.pio
|
.pio
|
||||||
.vscode
|
.vscode/
|
||||||
|
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,14 +1,7 @@
|
|||||||
# Multi Roomba Rover
|
# 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
|
Docs coming "soon"
|
||||||
|
|
||||||
on each roomba:
|
## Basic installation
|
||||||
- an esp32
|
-
|
||||||
- a level shifter
|
|
||||||
- DONT FORGET THE BRC PIN PULSE
|
|
||||||
- a power supply
|
|
||||||
- an openIPC camera
|
|
||||||
- USB wifi card
|
|
||||||
- microphone
|
|
||||||
- speaker
|
|
||||||
- MAYBE a master relay which can be turned off programatically to save the roomba from discharging. based on battery voltage plus urgent battery #?
|
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#configuration for roverd
|
||||||
|
name: dummy1
|
||||||
|
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
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#configuration for roverd
|
||||||
|
name: dummy2
|
||||||
|
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
|
||||||
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.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,26 +0,0 @@
|
|||||||
# notes and ideas
|
|
||||||
|
|
||||||
## frontend
|
|
||||||
|
|
||||||
each frontend module will have a JSON state update over socket.io
|
|
||||||
each module will export a function to update it's state
|
|
||||||
|
|
||||||
use these functions to update all modules on connect, from a UI init state event.
|
|
||||||
this event will be sent all of the module data at once, in JSON
|
|
||||||
|
|
||||||
split each JSON element to the UI element functions
|
|
||||||
|
|
||||||
one single system for loading and saving user settings
|
|
||||||
|
|
||||||
|
|
||||||
## esp-server comms
|
|
||||||
|
|
||||||
the ESP will report to the server when it connects:
|
|
||||||
|
|
||||||
rover's name
|
|
||||||
enable / disable for each motor
|
|
||||||
camera IP address
|
|
||||||
battery info (for different battery behaviors):
|
|
||||||
- full number
|
|
||||||
- warn number
|
|
||||||
- urgent number
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +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
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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}}"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
elif command -v rpicam-vid >/dev/null 2>&1; then
|
||||||
|
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
|
||||||
|
elif command -v libcamera-vid >/dev/null 2>&1; then
|
||||||
|
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
|
||||||
|
else
|
||||||
|
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"
|
||||||
|
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() {
|
||||||
|
"${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
|
||||||
|
done
|
||||||
Executable
+264
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Installer for the roverd agent on Raspberry Pi
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BINARY_SRC="dist/roverd"
|
||||||
|
CONFIG_SRC="pi/roverd/roverd.sample.yaml"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: sudo ./pi/install_roverd.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-b, --binary <path> Path to the roverd binary (default: dist/roverd)
|
||||||
|
-c, --config <path> Source config to install if /etc/roverd.yaml is missing
|
||||||
|
(default: pi/roverd/roverd.sample.yaml)
|
||||||
|
-h, --help Show this help text
|
||||||
|
|
||||||
|
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/audio helpers and systemd units
|
||||||
|
* enable roverd.service and media publisher/listener services
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-b|--binary)
|
||||||
|
BINARY_SRC="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-c|--config)
|
||||||
|
CONFIG_SRC="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
echo "Please run as root (sudo)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$BINARY_SRC" ]]; then
|
||||||
|
echo "Binary not found at $BINARY_SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_SRC" ]]; then
|
||||||
|
echo "Config source not found at $CONFIG_SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ensure_user() {
|
||||||
|
local user="$1"
|
||||||
|
local groups="${2:-}"
|
||||||
|
if ! id -u "$user" >/dev/null 2>&1; then
|
||||||
|
if [[ -n "$groups" ]]; then
|
||||||
|
useradd -r -s /usr/sbin/nologin -G "$groups" "$user"
|
||||||
|
else
|
||||||
|
useradd -r -s /usr/sbin/nologin "$user"
|
||||||
|
fi
|
||||||
|
elif [[ -n "$groups" ]]; then
|
||||||
|
usermod -a -G "$groups" "$user"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! command -v rpicam-vid >/dev/null 2>&1 && ! command -v libcamera-vid >/dev/null 2>&1; then
|
||||||
|
log "WARNING: neither rpicam-vid nor libcamera-vid found in PATH; install libcamera-apps."
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_video_deps() {
|
||||||
|
if command -v ffmpeg >/dev/null 2>&1 && (command -v rpicam-vid >/dev/null 2>&1 || command -v libcamera-vid >/dev/null 2>&1); then
|
||||||
|
log "Video dependencies already installed; skipping apt install"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "Installing video dependencies (libcamera-apps, ffmpeg)..."
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends libcamera-apps ffmpeg
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
CONFIG_DEST="/etc/roverd.yaml"
|
||||||
|
CONFIG_EXISTS=0
|
||||||
|
if [[ -f "$CONFIG_DEST" ]]; then
|
||||||
|
CONFIG_EXISTS=1
|
||||||
|
log "Existing $CONFIG_DEST found; leaving it in place"
|
||||||
|
else
|
||||||
|
install -D -o roverd -g roverd -m 0640 "$CONFIG_SRC" "$CONFIG_DEST"
|
||||||
|
log "Installed sample config to $CONFIG_DEST (edit before starting service)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
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=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
|
||||||
|
# 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
|
||||||
|
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 audio-only-publisher audio-forward-listener"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Install complete"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
BIN_DIR ?= ../../dist
|
||||||
|
GOOS ?= linux
|
||||||
|
GOARCH ?= arm
|
||||||
|
GOARM ?= 6
|
||||||
|
|
||||||
|
.PHONY: build pi-build dummy clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o $(BIN_DIR)/roverd ./cmd/roverd
|
||||||
|
|
||||||
|
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 $(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
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
autoChargeTimeout = 5 * time.Second
|
||||||
|
autoChargeCooldown = 0 * time.Minute
|
||||||
|
sourceHomeBase = 1 << 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type AutoChargeController struct {
|
||||||
|
adapter *SerialAdapter
|
||||||
|
events chan<- RoverEvent
|
||||||
|
logger *log.Logger
|
||||||
|
timerStart time.Time
|
||||||
|
cooldownUntil time.Time
|
||||||
|
lastState byte
|
||||||
|
lastSources byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAutoChargeController(adapter *SerialAdapter, events chan<- RoverEvent, logger *log.Logger) *AutoChargeController {
|
||||||
|
return &AutoChargeController{
|
||||||
|
adapter: adapter,
|
||||||
|
events: events,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoChargeController) Run(ctx context.Context, samples <-chan SensorSample) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case sample := <-samples:
|
||||||
|
a.processSample(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoChargeController) processSample(sample SensorSample) {
|
||||||
|
now := time.Now()
|
||||||
|
docked := sample.ChargeSources&sourceHomeBase != 0
|
||||||
|
charging := isCharging(sample.ChargingState)
|
||||||
|
|
||||||
|
if !docked || charging {
|
||||||
|
if !a.timerStart.IsZero() {
|
||||||
|
a.emitEvent("autoCharge.timerCleared", map[string]any{
|
||||||
|
"durationMs": time.Since(a.timerStart).Milliseconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a.timerStart = time.Time{}
|
||||||
|
a.lastState = sample.ChargingState
|
||||||
|
a.lastSources = sample.ChargeSources
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// docked but not charging
|
||||||
|
if a.cooldownUntil.After(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.timerStart.IsZero() {
|
||||||
|
a.timerStart = now
|
||||||
|
a.emitEvent("autoCharge.timerStarted", map[string]any{
|
||||||
|
"chargingState": sample.ChargingState,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Sub(a.timerStart) >= autoChargeTimeout {
|
||||||
|
if err := a.adapter.SeekDock(); err != nil {
|
||||||
|
a.emitEvent("autoCharge.seekDockError", map[string]any{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
a.emitEvent("autoCharge.seekDockIssued", map[string]any{
|
||||||
|
"waitingMs": autoChargeTimeout.Milliseconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a.timerStart = time.Time{}
|
||||||
|
a.cooldownUntil = now.Add(autoChargeCooldown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCharging(state byte) bool {
|
||||||
|
switch state {
|
||||||
|
case 1, 2, 3, 4:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoChargeController) emitEvent(event string, data map[string]any) {
|
||||||
|
if a.events == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case a.events <- RoverEvent{
|
||||||
|
Type: "event",
|
||||||
|
Event: event,
|
||||||
|
Ts: time.Now().UnixMilli(),
|
||||||
|
Data: data,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//go:build !dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BRCPulser struct {
|
||||||
|
cfg BRCConfig
|
||||||
|
logger *log.Logger
|
||||||
|
line *gpiocdev.Line
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
|
||||||
|
chip := cfg.GPIOChip
|
||||||
|
if chip == "" {
|
||||||
|
chip = "gpiochip0"
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := gpiocdev.RequestLine(
|
||||||
|
chip,
|
||||||
|
cfg.GPIOPin,
|
||||||
|
gpiocdev.AsOutput(1),
|
||||||
|
gpiocdev.WithConsumer("roverd-brc"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &BRCPulser{cfg: cfg, logger: logger, line: line}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BRCPulser) Close() {
|
||||||
|
if b.line != nil {
|
||||||
|
_ = b.line.SetValue(1)
|
||||||
|
b.line.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BRCPulser) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(b.cfg.PulseEvery.Duration)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
b.pulseOnce()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BRCPulser) pulseOnce() {
|
||||||
|
if b.line == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := b.line.SetValue(0); err != nil {
|
||||||
|
b.logger.Printf("brc pulse low: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(b.cfg.PulseWidth.Duration)
|
||||||
|
if err := b.line.SetValue(1); err != nil {
|
||||||
|
b.logger.Printf("brc pulse high: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BRCPulser struct{}
|
||||||
|
|
||||||
|
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
|
||||||
|
logger.Printf("[dummy] BRC configured on pin %d", cfg.GPIOPin)
|
||||||
|
return &BRCPulser{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BRCPulser) Close() {}
|
||||||
|
|
||||||
|
func (b *BRCPulser) Start(ctx context.Context) {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
roverd "multiroombarover/pi/roverd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var cfgPath string
|
||||||
|
flag.StringVar(&cfgPath, "config", "/etc/roverd.yaml", "path to roverd configuration file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := roverd.LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
|
||||||
|
log.Fatalf("prepare media env: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.New(os.Stdout, "roverd: ", log.LstdFlags|log.Lmicroseconds|log.LUTC)
|
||||||
|
|
||||||
|
serialPort, err := roverd.OpenSerial(cfg.Serial)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("open serial: %v", err)
|
||||||
|
}
|
||||||
|
defer serialPort.Close()
|
||||||
|
|
||||||
|
var pulser *roverd.BRCPulser
|
||||||
|
if cfg.BRC.Enabled() {
|
||||||
|
pulser, err = roverd.NewBRCPulser(cfg.BRC, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("init BRC pulser: %v", err)
|
||||||
|
}
|
||||||
|
defer pulser.Close()
|
||||||
|
pulser.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
sensorFrames := make(chan []byte, 8)
|
||||||
|
sensorSamples := make(chan roverd.SensorSample, 8)
|
||||||
|
eventStream := make(chan roverd.RoverEvent, 16)
|
||||||
|
|
||||||
|
streamer := roverd.NewSensorStreamer(serialPort, sensorFrames, sensorSamples, logger)
|
||||||
|
go streamer.Run(ctx)
|
||||||
|
|
||||||
|
adapter := roverd.NewSerialAdapter(serialPort, 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, cameraServo, nightVision, logger)
|
||||||
|
|
||||||
|
retryDelay := time.Second
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
if err := client.Run(ctx); err != nil {
|
||||||
|
logger.Printf("websocket loop ended: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(retryDelay):
|
||||||
|
}
|
||||||
|
|
||||||
|
if retryDelay < 30*time.Second {
|
||||||
|
retryDelay *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
type helloMessage struct {
|
||||||
|
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 {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Timestamp int64 `json:"ts"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboundMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
DriveDirect *driveDirectPayload `json:"driveDirect,omitempty"`
|
||||||
|
MotorPWM *motorPWMPayload `json:"motorPwm,omitempty"`
|
||||||
|
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 {
|
||||||
|
Left int `json:"left"`
|
||||||
|
Right int `json:"right"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type motorPWMPayload struct {
|
||||||
|
Main int `json:"main"`
|
||||||
|
Side int `json:"side"`
|
||||||
|
Vacuum int `json:"vacuum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sensorStreamPayload struct {
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SerialConfig struct {
|
||||||
|
Device string `yaml:"device"`
|
||||||
|
Baud int `yaml:"baud"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Duration struct {
|
||||||
|
time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
var raw string
|
||||||
|
if err := value.Decode(&raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.Duration = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Duration) MarshalYAML() (interface{}, error) {
|
||||||
|
return d.Duration.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type BRCConfig struct {
|
||||||
|
GPIOPin int `yaml:"gpioPin"`
|
||||||
|
GPIOChip string `yaml:"gpioChip"`
|
||||||
|
PulseEvery Duration `yaml:"pulseEvery"`
|
||||||
|
PulseWidth Duration `yaml:"pulseWidth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b BRCConfig) Enabled() bool {
|
||||||
|
return b.GPIOPin >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatteryConfig struct {
|
||||||
|
Full int `yaml:"full"`
|
||||||
|
Warn int `yaml:"warn"`
|
||||||
|
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"`
|
||||||
|
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"`
|
||||||
|
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) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := Config{
|
||||||
|
MaxWheelMMs: 500,
|
||||||
|
BRC: BRCConfig{
|
||||||
|
GPIOPin: 4,
|
||||||
|
GPIOChip: "gpiochip0",
|
||||||
|
PulseEvery: Duration{
|
||||||
|
Duration: time.Minute,
|
||||||
|
},
|
||||||
|
PulseWidth: Duration{
|
||||||
|
Duration: time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Media: MediaConfig{
|
||||||
|
PublishPort: 9000,
|
||||||
|
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if cfg.Serial.Device == "" || cfg.Serial.Baud == 0 {
|
||||||
|
return nil, errors.New("serial device/baud required")
|
||||||
|
}
|
||||||
|
if cfg.Battery.Full == 0 {
|
||||||
|
return nil, errors.New("battery thresholds required")
|
||||||
|
}
|
||||||
|
if cfg.MaxWheelMMs <= 0 || cfg.MaxWheelMMs > 500 {
|
||||||
|
return nil, fmt.Errorf("maxWheelSpeed must be 1-500, got %d", cfg.MaxWheelMMs)
|
||||||
|
}
|
||||||
|
if cfg.BRC.GPIOChip == "" {
|
||||||
|
cfg.BRC.GPIOChip = "gpiochip0"
|
||||||
|
}
|
||||||
|
if cfg.Media.Manage && cfg.Media.Service == "" {
|
||||||
|
return nil, errors.New("media.manage requires media.service")
|
||||||
|
}
|
||||||
|
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
||||||
|
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||||
|
}
|
||||||
|
if cfg.Media.VideoBitrate <= 0 {
|
||||||
|
cfg.Media.VideoBitrate = 3000000
|
||||||
|
}
|
||||||
|
if cfg.Media.PublishPort <= 0 {
|
||||||
|
cfg.Media.PublishPort = 9000
|
||||||
|
}
|
||||||
|
if cfg.Media.PublishURL == "" {
|
||||||
|
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("derive publishUrl: %w", err)
|
||||||
|
}
|
||||||
|
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 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 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return "", errors.New("serverUrl missing host")
|
||||||
|
}
|
||||||
|
if port <= 0 {
|
||||||
|
port = 9000
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
type RoverEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
Data map[string]any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
nhooyr.io/websocket v1.8.17
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.38.0 // indirect
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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=
|
||||||
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||||
|
github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI=
|
||||||
|
github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
|
||||||
|
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
|
||||||
|
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||||
|
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const publisherEnvPath = "/var/lib/roverd/video.env"
|
||||||
|
|
||||||
|
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||||
|
if media.PublishURL == "" {
|
||||||
|
return fmt.Errorf("media publishUrl missing")
|
||||||
|
}
|
||||||
|
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", 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaSupervisor struct {
|
||||||
|
cfg MediaConfig
|
||||||
|
audio AudioConfig
|
||||||
|
logger *log.Logger
|
||||||
|
client *http.Client
|
||||||
|
checkInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
interval := cfg.HealthInterval.Duration
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
var client *http.Client
|
||||||
|
if cfg.HealthURL != "" {
|
||||||
|
client = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
}
|
||||||
|
return &MediaSupervisor{
|
||||||
|
cfg: cfg,
|
||||||
|
audio: audio,
|
||||||
|
logger: logger,
|
||||||
|
client: client,
|
||||||
|
checkInterval: interval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaSupervisor) Start(ctx context.Context) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(m.checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
if err := m.checkAndRepair(); err != nil {
|
||||||
|
m.logger.Printf("media supervisor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := m.checkAndRepair(); err != nil {
|
||||||
|
m.logger.Printf("media supervisor: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error {
|
||||||
|
if m == nil {
|
||||||
|
return errors.New("media supervisor disabled")
|
||||||
|
}
|
||||||
|
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "start", "stop", "restart", "reload", "status":
|
||||||
|
return m.runSystemctl(ctx, action)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown media action: %s", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaSupervisor) checkAndRepair() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if m.checkHealth(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.logger.Printf("media supervisor: health check failed, restarting %s", m.cfg.Service)
|
||||||
|
if err := m.runSystemctl(ctx, "restart"); err != nil {
|
||||||
|
return fmt.Errorf("restart mediamtx: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
|
||||||
|
if m.client == nil || m.cfg.HealthURL == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.HealthURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Printf("media supervisor: health request: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Printf("media supervisor: health request failed: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
m.logger.Printf("media supervisor: unexpected health status %d", resp.StatusCode)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error {
|
||||||
|
if m.cfg.Service == "" {
|
||||||
|
return errors.New("no media service configured")
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,80 @@
|
|||||||
|
# Sample configuration for roverd
|
||||||
|
name: roomba-alpha
|
||||||
|
color: "#4DB6AC"
|
||||||
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
|
serial:
|
||||||
|
device: /dev/ttyAMA0
|
||||||
|
baud: 115200
|
||||||
|
brc:
|
||||||
|
gpioPin: 4
|
||||||
|
gpioChip: gpiochip0
|
||||||
|
pulseEvery: 1m
|
||||||
|
pulseWidth: 1s
|
||||||
|
battery:
|
||||||
|
full: 2068
|
||||||
|
warn: 1700
|
||||||
|
urgent: 1650
|
||||||
|
maxWheelSpeed: 350
|
||||||
|
media:
|
||||||
|
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
|
||||||
|
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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Sample configuration for roverd
|
||||||
|
name: roomba-alpha
|
||||||
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
|
serial:
|
||||||
|
device: /dev/ttyAMA0
|
||||||
|
baud: 115200
|
||||||
|
brc:
|
||||||
|
gpioPin: 4
|
||||||
|
gpioChip: gpiochip0
|
||||||
|
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
|
||||||
|
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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultStreamPackets = []byte{100, 21, 34}
|
||||||
|
packetSizes = map[byte]int{
|
||||||
|
100: 80,
|
||||||
|
21: 1,
|
||||||
|
34: 1,
|
||||||
|
}
|
||||||
|
expectedPayloadLength = func() int {
|
||||||
|
sum := 0
|
||||||
|
for _, id := range defaultStreamPackets {
|
||||||
|
sum += 1 + packetSizes[id]
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
|
||||||
|
type SensorSample struct {
|
||||||
|
Timestamp int64
|
||||||
|
ChargingState byte
|
||||||
|
ChargeSources byte
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//go:build !dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sensorHeader = 19
|
||||||
|
sensorReadTimeout = 150 * time.Millisecond
|
||||||
|
sensorThrottleMinimum = 50 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type SensorStreamer struct {
|
||||||
|
r io.Reader
|
||||||
|
rawOut chan<- []byte
|
||||||
|
parsed chan<- SensorSample
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
||||||
|
return &SensorStreamer{r: r, rawOut: rawOut, parsed: parsed, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||||
|
reader := bufio.NewReader(s.r)
|
||||||
|
var nextSend time.Time
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if header != sensorHeader {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nBytes, err := reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frame := make([]byte, int(nBytes)+3)
|
||||||
|
frame[0] = sensorHeader
|
||||||
|
frame[1] = nBytes
|
||||||
|
if _, err := io.ReadFull(reader, frame[2:]); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validateChecksum(frame) {
|
||||||
|
s.logger.Printf("sensor checksum failed: %s", hex.EncodeToString(frame))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if !nextSend.IsZero() && now.Before(nextSend) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextSend = now.Add(sensorThrottleMinimum)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.rawOut <- frame:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.parsed != nil {
|
||||||
|
if sample, ok := decodeSensorSample(frame); ok {
|
||||||
|
select {
|
||||||
|
case s.parsed <- sample:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateChecksum(buf []byte) bool {
|
||||||
|
var sum int
|
||||||
|
for _, b := range buf {
|
||||||
|
sum += int(b)
|
||||||
|
}
|
||||||
|
return byte(sum&0xFF) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSensorSample(frame []byte) (SensorSample, bool) {
|
||||||
|
if len(frame) < 3 {
|
||||||
|
return SensorSample{}, false
|
||||||
|
}
|
||||||
|
nBytes := int(frame[1])
|
||||||
|
if nBytes+3 != len(frame) {
|
||||||
|
return SensorSample{}, false
|
||||||
|
}
|
||||||
|
payload := frame[2 : 2+nBytes]
|
||||||
|
if len(payload) != expectedPayloadLength {
|
||||||
|
return SensorSample{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := 0
|
||||||
|
var sample SensorSample
|
||||||
|
var seen byte
|
||||||
|
for idx < len(payload) {
|
||||||
|
id := payload[idx]
|
||||||
|
idx++
|
||||||
|
size, ok := packetSizes[id]
|
||||||
|
if !ok {
|
||||||
|
return SensorSample{}, false
|
||||||
|
}
|
||||||
|
if idx+size > len(payload) {
|
||||||
|
return SensorSample{}, false
|
||||||
|
}
|
||||||
|
segment := payload[idx : idx+size]
|
||||||
|
switch id {
|
||||||
|
case 21:
|
||||||
|
sample.ChargingState = segment[0]
|
||||||
|
seen |= 1
|
||||||
|
case 34:
|
||||||
|
sample.ChargeSources = segment[0]
|
||||||
|
seen |= 2
|
||||||
|
}
|
||||||
|
idx += size
|
||||||
|
}
|
||||||
|
sample.Timestamp = time.Now().UnixMilli()
|
||||||
|
return sample, seen&3 == 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//go:build dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sensorHeader = 19
|
||||||
|
|
||||||
|
type SensorStreamer struct {
|
||||||
|
rawOut chan<- []byte
|
||||||
|
parsed chan<- SensorSample
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSensorStreamer(_ interface{}, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
||||||
|
return &SensorStreamer{rawOut: rawOut, parsed: parsed, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
frame := buildDummyFrame()
|
||||||
|
select {
|
||||||
|
case s.rawOut <- frame:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
sample := SensorSample{
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
ChargingState: 3, // trickle charging
|
||||||
|
ChargeSources: 0b10, // home base present
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.parsed <- sample:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDummyFrame() []byte {
|
||||||
|
payload := make([]byte, 0, expectedPayloadLength)
|
||||||
|
payload = append(payload, 100)
|
||||||
|
group := make([]byte, packetSizes[100])
|
||||||
|
group[0] = byte(rand.Intn(16)) // bumps
|
||||||
|
payload = append(payload, group...)
|
||||||
|
payload = append(payload, 21, 3)
|
||||||
|
payload = append(payload, 34, 0b10)
|
||||||
|
|
||||||
|
buf := make([]byte, 0, len(payload)+3)
|
||||||
|
buf = append(buf, sensorHeader, byte(len(payload)))
|
||||||
|
buf = append(buf, payload...)
|
||||||
|
checksum := calcChecksum(buf)
|
||||||
|
buf = append(buf, checksum)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcChecksum(buf []byte) byte {
|
||||||
|
sum := 0
|
||||||
|
for _, b := range buf {
|
||||||
|
sum += int(b)
|
||||||
|
}
|
||||||
|
return byte((-sum) & 0xFF)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//go:build !dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tarm/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SerialAdapter struct {
|
||||||
|
port io.ReadWriteCloser
|
||||||
|
encoder *base64.Encoding
|
||||||
|
mu sync.Mutex
|
||||||
|
log *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenSerial(cfg SerialConfig) (*serial.Port, error) {
|
||||||
|
return serial.OpenPort(&serial.Config{
|
||||||
|
Name: cfg.Device,
|
||||||
|
Baud: cfg.Baud,
|
||||||
|
ReadTimeout: sensorReadTimeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSerialAdapter(port io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
|
||||||
|
return &SerialAdapter{
|
||||||
|
port: port,
|
||||||
|
encoder: base64.StdEncoding,
|
||||||
|
log: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) write(buf []byte) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
n, err := s.port.Write(buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != len(buf) {
|
||||||
|
return fmt.Errorf("short write %d/%d", n, len(buf))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) DriveDirect(left, right int) error {
|
||||||
|
payload := []byte{
|
||||||
|
145,
|
||||||
|
byte((right >> 8) & 0xFF),
|
||||||
|
byte(right & 0xFF),
|
||||||
|
byte((left >> 8) & 0xFF),
|
||||||
|
byte(left & 0xFF),
|
||||||
|
}
|
||||||
|
return s.write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
||||||
|
payload := []byte{
|
||||||
|
144,
|
||||||
|
byte(main & 0xFF),
|
||||||
|
byte(side & 0xFF),
|
||||||
|
byte(vacuum & 0xFF),
|
||||||
|
}
|
||||||
|
return s.write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return errors.New("sensor stream requires packets")
|
||||||
|
}
|
||||||
|
payload := []byte{148, byte(len(packets))}
|
||||||
|
payload = append(payload, packets...)
|
||||||
|
return s.write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
||||||
|
state := byte(1)
|
||||||
|
if pause {
|
||||||
|
state = 0
|
||||||
|
}
|
||||||
|
return s.write([]byte{150, state})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)})
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//go:build dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dummyPort struct{}
|
||||||
|
|
||||||
|
func (dummyPort) Read(p []byte) (int, error) { return 0, io.EOF }
|
||||||
|
func (dummyPort) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
func (dummyPort) Close() error { return nil }
|
||||||
|
|
||||||
|
func OpenSerial(cfg SerialConfig) (io.ReadWriteCloser, error) {
|
||||||
|
return dummyPort{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SerialAdapter struct {
|
||||||
|
log *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSerialAdapter(_ io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
|
||||||
|
return &SerialAdapter{log: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) DriveDirect(left, right int) error {
|
||||||
|
s.log.Printf("[dummy] drive L=%d R=%d", left, right)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
||||||
|
s.log.Printf("[dummy] motor main=%d side=%d vacuum=%d", main, side, vacuum)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return errors.New("sensor stream requires packets")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||||
|
s.log.Printf("[dummy] raw %v", raw)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WSClient struct {
|
||||||
|
cfg *Config
|
||||||
|
adapter *SerialAdapter
|
||||||
|
sensorFrames <-chan []byte
|
||||||
|
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, 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := c.ensureSensorStream(); err != nil {
|
||||||
|
c.log.Printf("sensor stream init failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
conn.Close(websocket.StatusNormalClosure, "context done")
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error {
|
||||||
|
for {
|
||||||
|
_, data, err := conn.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var msg inboundMessage
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
c.log.Printf("invalid command: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status := "ok"
|
||||||
|
cmdErr := c.dispatch(ctx, &msg)
|
||||||
|
if cmdErr != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
ack := ackMessage{
|
||||||
|
Type: "ack",
|
||||||
|
ID: msg.ID,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if cmdErr != nil {
|
||||||
|
ack.Error = cmdErr.Error()
|
||||||
|
}
|
||||||
|
if err := writeJSON(ctx, conn, ack); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||||
|
switch {
|
||||||
|
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)
|
||||||
|
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 {
|
||||||
|
return c.adapter.StartSensorStream(defaultStreamPackets)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case msg.Raw != "" && len(msg.Raw) > 0:
|
||||||
|
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("raw decode: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.adapter.SendRaw(buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(buf) > 0 && isModeOpcode(buf[0]) {
|
||||||
|
return c.ensureSensorStream()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case msg.Media != nil:
|
||||||
|
if c.media == nil {
|
||||||
|
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(),
|
||||||
|
Data: base64.StdEncoding.EncodeToString(frame),
|
||||||
|
}
|
||||||
|
if err := writeJSON(ctx, conn, msg); err != nil {
|
||||||
|
c.log.Printf("sensor send failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
||||||
|
if c.events == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case evt := <-c.events:
|
||||||
|
if evt.Type == "" {
|
||||||
|
evt.Type = "event"
|
||||||
|
}
|
||||||
|
if err := writeJSON(ctx, conn, evt); err != nil {
|
||||||
|
c.log.Printf("event send failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.Write(ctx, websocket.MessageText, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(value, min, max int) int {
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) ensureSensorStream() error {
|
||||||
|
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
switch op {
|
||||||
|
case 128, 131, 132:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Multi-Roomba rover control agent
|
||||||
|
After=network-online.target mediamtx.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Rover Video Publisher (libcamera -> SRT)
|
||||||
|
After=network-online.target roverd.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=roverd
|
||||||
|
Group=roverd
|
||||||
|
WorkingDirectory=/var/lib/roverd
|
||||||
|
EnvironmentFile=/var/lib/roverd/video.env
|
||||||
|
ExecStart=/usr/local/bin/video-publisher
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -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
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[env:esp32s3]
|
|
||||||
platform = espressif32
|
|
||||||
board = esp32-s3-devkitc-1
|
|
||||||
framework = arduino
|
|
||||||
monitor_speed = 115200
|
|
||||||
|
|
||||||
; Enable verbose FreeRTOS symbols in Arduino builds (optional but handy for demos)
|
|
||||||
build_flags =
|
|
||||||
-DCORE_DEBUG_LEVEL=3
|
|
||||||
@@ -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.
@@ -0,0 +1,80 @@
|
|||||||
|
admins:
|
||||||
|
- username: admin
|
||||||
|
password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass
|
||||||
|
discord_id: "1234567890"
|
||||||
|
lockdown: false
|
||||||
|
- username: lockdown
|
||||||
|
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/path). The UI will always request
|
||||||
|
# http://<base>/<roverId>/whep
|
||||||
|
# Example: http://192.168.0.86:8889/video
|
||||||
|
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
require('./src/globals/logger');
|
||||||
|
require('./src/globals/config');
|
||||||
|
require('./src/globals/http');
|
||||||
|
require('./src/globals/io');
|
||||||
|
require('./src/globals/ws');
|
||||||
|
|
||||||
|
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');
|
||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MEDIAMTX_VERSION="1.15.3"
|
||||||
|
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
||||||
|
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||||
|
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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${SUDO_USER:-}" || "${SUDO_USER}" == "root" ]]; then
|
||||||
|
echo "Run this script via 'sudo' from the normal user that owns the repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_USER="$SUDO_USER"
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
SERVER_DIR="$SCRIPT_DIR"
|
||||||
|
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||||
|
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||||
|
|
||||||
|
echo "[1/6] Installing dependencies..."
|
||||||
|
dnf install -y nodejs npm curl tar >/dev/null
|
||||||
|
NODE_BIN="$(command -v node)"
|
||||||
|
|
||||||
|
echo "[2/6] Installing Node production deps..."
|
||||||
|
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||||
|
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||||
|
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||||
|
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmpdir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
|
arch=$(uname -m)
|
||||||
|
case "$arch" in
|
||||||
|
x86_64|amd64)
|
||||||
|
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
||||||
|
;;
|
||||||
|
aarch64)
|
||||||
|
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
||||||
|
;;
|
||||||
|
armv7l)
|
||||||
|
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture: $arch" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "[3/6] Installing mediaMTX ${MEDIAMTX_VERSION}..."
|
||||||
|
curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
||||||
|
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
||||||
|
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
||||||
|
|
||||||
|
mkdir -p "$MEDIAMTX_CONF_DIR"
|
||||||
|
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||||
|
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||||
|
rm -f "$MEDIAMTX_CONFIG"
|
||||||
|
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
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=$TARGET_USER
|
||||||
|
Group=$TARGET_USER
|
||||||
|
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
||||||
|
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||||
|
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Multi-Roomba Rover control server
|
||||||
|
After=network-online.target mediamtx.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=$TARGET_USER
|
||||||
|
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
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
||||||
|
|
||||||
|
echo "[5/6] Enabling services..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now mediamtx.service
|
||||||
|
systemctl enable --now multirover.service
|
||||||
|
systemctl restart mediamtx.service
|
||||||
|
systemctl restart multirover.service
|
||||||
|
|
||||||
|
echo "[6/6] Done."
|
||||||
|
echo
|
||||||
|
echo "Services installed:"
|
||||||
|
echo " mediamtx.service (WebRTC fan-out)"
|
||||||
|
echo " multirover.service (Node.js control server)"
|
||||||
|
echo
|
||||||
|
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
|
||||||
|
logLevel: info
|
||||||
|
|
||||||
|
api: yes
|
||||||
|
apiAddress: 0.0.0.0:9997
|
||||||
|
metrics: yes
|
||||||
|
metricsAddress: 0.0.0.0:9998
|
||||||
|
pprof: no
|
||||||
|
pprofAddress: 127.0.0.1:9999
|
||||||
|
|
||||||
|
rtsp: no
|
||||||
|
rtmp: no
|
||||||
|
hls: no
|
||||||
|
|
||||||
|
webrtc: yes
|
||||||
|
webrtcLocalUDPAddress: :8189
|
||||||
|
webrtcLocalTCPAddress: :8189
|
||||||
|
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
|
||||||
|
webrtcICEServers2:
|
||||||
|
# Google public STUN (world-wide, very commonly used)
|
||||||
|
- url: stun:stun.l.google.com:19302
|
||||||
|
- url: stun:stun1.l.google.com:19302
|
||||||
|
- url: stun:stun2.l.google.com:19302
|
||||||
|
- url: stun:stun3.l.google.com:19302
|
||||||
|
- url: stun:stun4.l.google.com:19302
|
||||||
|
|
||||||
|
# Cloudflare STUN (anycast, global PoPs)
|
||||||
|
- url: stun:stun.cloudflare.com:3478
|
||||||
|
|
||||||
|
srt: yes
|
||||||
|
srtAddress: :9000
|
||||||
|
|
||||||
|
authMethod: http
|
||||||
|
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
||||||
|
authHTTPExclude:
|
||||||
|
- action: api
|
||||||
|
- action: metrics
|
||||||
|
- action: pprof
|
||||||
|
|
||||||
|
paths:
|
||||||
|
all:
|
||||||
|
source: publisher
|
||||||
|
sourceOnDemand: no
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "multiroombarover-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "nodemon index.js",
|
||||||
|
"check:media": "node scripts/checkMedia.js"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.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
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/bitmap.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const api = process.env.MEDIAMTX_API || 'http://127.0.0.1:9997';
|
||||||
|
|
||||||
|
function fetchJSON(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(api + path, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const list = await fetchJSON('/v3/paths/list');
|
||||||
|
if (!list.items || !list.items.length) {
|
||||||
|
console.log('No active paths');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.items.forEach((item) => {
|
||||||
|
console.log(
|
||||||
|
`${item.name.padEnd(12)} ready=${item.ready} tracks=${item.tracks.join(',') || 'none'} bytes=${item.bytesReceived}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('check-media failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
const passwordFromArg = process.argv[2];
|
||||||
|
|
||||||
|
async function hashPassword(password) {
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
console.log(`Password: ${password}`);
|
||||||
|
console.log(`Hash: ${hash}`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error hashing password:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordFromArg) {
|
||||||
|
hashPassword(passwordFromArg);
|
||||||
|
} else {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question('Password to hash: ', (answer) => {
|
||||||
|
rl.close();
|
||||||
|
hashPassword(answer);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: process.env.PORT || 8080,
|
||||||
|
staticDir: path.join(__dirname, '..', '..', 'public'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(config.staticDir, { index: false }));
|
||||||
|
|
||||||
|
const httpServer = http.createServer(app);
|
||||||
|
|
||||||
|
module.exports = { app, httpServer };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const { Server: SocketIOServer } = require('socket.io');
|
||||||
|
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;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const sinks = new Set();
|
||||||
|
|
||||||
|
function notifySinks(level, label, args) {
|
||||||
|
if (!sinks.size) return;
|
||||||
|
const entry = {
|
||||||
|
level,
|
||||||
|
label: label || null,
|
||||||
|
args,
|
||||||
|
message: args.map((value) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (err) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}).join(' '),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
sinks.forEach((sink) => {
|
||||||
|
try {
|
||||||
|
sink(entry);
|
||||||
|
} catch (err) {
|
||||||
|
// avoid recursive logging
|
||||||
|
console.error(entry.timestamp, '[ERROR]', '[logger]', 'Log sink failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stamp(level, label, args) {
|
||||||
|
const fields = [new Date().toISOString(), `[${level}]`];
|
||||||
|
if (label) {
|
||||||
|
fields.push(`[${label}]`);
|
||||||
|
}
|
||||||
|
notifySinks(level, label, args);
|
||||||
|
return [...fields, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseLogger(label) {
|
||||||
|
return {
|
||||||
|
info: (...args) => console.log(...stamp('INFO', label, args)),
|
||||||
|
warn: (...args) => console.warn(...stamp('WARN', label, args)),
|
||||||
|
error: (...args) => console.error(...stamp('ERROR', label, args)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSink(fn) {
|
||||||
|
if (typeof fn !== 'function') return () => {};
|
||||||
|
sinks.add(fn);
|
||||||
|
return () => sinks.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...baseLogger(),
|
||||||
|
child: (label) => baseLogger(label),
|
||||||
|
registerSink,
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { httpServer } = require('./http');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
const roverWSS = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
httpServer.on('upgrade', (req, socket, head) => {
|
||||||
|
if (req.url.startsWith('/rover')) {
|
||||||
|
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
roverWSS.emit('connection', ws, req);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roverWSS.on('connection', () => {
|
||||||
|
logger.info('Rover websocket connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = roverWSS;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
|
||||||
|
const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml');
|
||||||
|
|
||||||
|
let cachedConfig;
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
const file = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
cachedConfig = yaml.load(file);
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadConfig,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
const HEADER = 0x13;
|
||||||
|
const CHARGING_STATE = {
|
||||||
|
0: 'not charging',
|
||||||
|
1: 'reconditioning charging',
|
||||||
|
2: 'full charging',
|
||||||
|
3: 'trickle charging',
|
||||||
|
4: 'waiting',
|
||||||
|
5: 'charging fault',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OI_MODES = {
|
||||||
|
1: 'off',
|
||||||
|
2: 'passive',
|
||||||
|
3: 'safe',
|
||||||
|
4: 'full',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP100_LAYOUT = [
|
||||||
|
{ id: 7, key: 'bumpsAndWheelDrops', bytes: 1, parser: parseBumps },
|
||||||
|
{ id: 8, key: 'wall', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 9, key: 'cliffLeft', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 10, key: 'cliffFrontLeft', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 11, key: 'cliffFrontRight', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 12, key: 'cliffRight', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 13, key: 'virtualWall', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 14, key: 'wheelOvercurrents', bytes: 1, parser: parseWheelCurrents },
|
||||||
|
{ id: 15, key: 'dirtDetect', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 16, key: 'dirtDetectLeft', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 17, key: 'infraredCharacterOmni', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 18, key: 'buttons', bytes: 1, parser: parseButtons },
|
||||||
|
{ id: 19, key: 'distanceMm', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 20, key: 'angleDeg', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 21, key: 'chargingState', bytes: 1, parser: parseChargingState },
|
||||||
|
{ id: 22, key: 'voltageMv', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 23, key: 'currentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 24, key: 'batteryTemperatureC', bytes: 1, parser: parseInt },
|
||||||
|
{ id: 25, key: 'batteryChargeMah', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 26, key: 'batteryCapacityMah', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 27, key: 'wallSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 28, key: 'cliffLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 29, key: 'cliffFrontLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 30, key: 'cliffFrontRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 31, key: 'cliffRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 32, key: 'chargingSourcesAvailable', bytes: 1, parser: parseChargeSources },
|
||||||
|
{ id: 33, key: 'chargingSourcesReserved', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 34, key: 'chargingSources', bytes: 1, parser: parseChargeSources },
|
||||||
|
{ id: 35, key: 'oiMode', bytes: 1, parser: parseOiMode },
|
||||||
|
{ id: 36, key: 'songNumber', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 37, key: 'songPlaying', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 38, key: 'streamPacketCount', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 39, key: 'requestedVelocity', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 40, key: 'requestedRadius', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 41, key: 'requestedRightVelocity', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 42, key: 'requestedLeftVelocity', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 43, key: 'encoderCountsLeft', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 44, key: 'encoderCountsRight', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 45, key: 'lightBumper', bytes: 1, parser: parseLightBumper },
|
||||||
|
{ id: 46, key: 'lightBumpLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 47, key: 'lightBumpFrontLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 48, key: 'lightBumpCenterLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 49, key: 'lightBumpCenterRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 50, key: 'lightBumpFrontRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 51, key: 'lightBumpRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 52, key: 'infraredCharacterLeft', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 53, key: 'infraredCharacterRight', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 54, key: 'wheelLeftCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 55, key: 'wheelRightCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 56, key: 'mainBrushCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 57, key: 'sideBrushCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 58, key: 'stasis', bytes: 1, parser: parseBool },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP100_TOTAL = GROUP100_LAYOUT.reduce((sum, spec) => sum + spec.bytes, 0);
|
||||||
|
|
||||||
|
const TOP_LEVEL_PACKETS = {
|
||||||
|
100: GROUP100_TOTAL,
|
||||||
|
21: 1,
|
||||||
|
34: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSensorFrame(base64Data) {
|
||||||
|
if (!base64Data) return null;
|
||||||
|
const buf = Buffer.from(base64Data, 'base64');
|
||||||
|
if (buf.length < 4 || buf[0] !== HEADER) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nBytes = buf[1];
|
||||||
|
if (buf.length < nBytes + 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = buf.slice(2, 2 + nBytes);
|
||||||
|
const checksum = buf[2 + nBytes];
|
||||||
|
if (!validateChecksum(buf.slice(0, 2 + nBytes + 1), checksum)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const decoded = {};
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < payload.length) {
|
||||||
|
const packetId = payload[offset++];
|
||||||
|
const size = TOP_LEVEL_PACKETS[packetId];
|
||||||
|
if (!size || offset + size > payload.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segment = payload.slice(offset, offset + size);
|
||||||
|
offset += size;
|
||||||
|
if (packetId === 100) {
|
||||||
|
Object.assign(decoded, decodeGroup100(segment));
|
||||||
|
} else if (packetId === 21 && decoded.chargingState == null) {
|
||||||
|
decoded.chargingState = parseChargingState(segment);
|
||||||
|
} else if (packetId === 34 && decoded.chargingSources == null) {
|
||||||
|
decoded.chargingSources = parseChargeSources(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeGroup100(buf) {
|
||||||
|
if (buf.length !== GROUP100_TOTAL) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const values = {};
|
||||||
|
let offset = 0;
|
||||||
|
for (const spec of GROUP100_LAYOUT) {
|
||||||
|
const slice = buf.slice(offset, offset + spec.bytes);
|
||||||
|
offset += spec.bytes;
|
||||||
|
try {
|
||||||
|
values[spec.key] = spec.parser ? spec.parser(slice) : parseUInt(slice);
|
||||||
|
} catch (err) {
|
||||||
|
values[spec.key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBool(buf) {
|
||||||
|
return Boolean(buf[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUInt(buf) {
|
||||||
|
return buf.readUIntBE(0, buf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInt(buf) {
|
||||||
|
return buf.readIntBE(0, buf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBumps(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
return {
|
||||||
|
bumpRight: Boolean(value & 0x01),
|
||||||
|
bumpLeft: Boolean(value & 0x02),
|
||||||
|
wheelDropRight: Boolean(value & 0x04),
|
||||||
|
wheelDropLeft: Boolean(value & 0x08),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWheelCurrents(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
return {
|
||||||
|
sideBrush: Boolean(value & 0x01),
|
||||||
|
mainBrush: Boolean(value & 0x04),
|
||||||
|
rightWheel: Boolean(value & 0x08),
|
||||||
|
leftWheel: Boolean(value & 0x10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTON_LABELS = ['clean', 'spot', 'dock', 'minute', 'hour', 'day', 'schedule', 'clock'];
|
||||||
|
function parseButtons(buf) {
|
||||||
|
const v = buf[0];
|
||||||
|
const result = {};
|
||||||
|
BUTTON_LABELS.forEach((label, idx) => {
|
||||||
|
result[label] = Boolean(v & (1 << idx));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChargingState(buf) {
|
||||||
|
const code = buf[0];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
label: CHARGING_STATE[code] || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChargeSources(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
return {
|
||||||
|
internalCharger: Boolean(value & 0x01),
|
||||||
|
homeBase: Boolean(value & 0x02),
|
||||||
|
raw: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOiMode(buf) {
|
||||||
|
const code = buf[0];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
label: OI_MODES[code] || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIGHT_BUMPER_LABELS = ['left', 'frontLeft', 'centerLeft', 'centerRight', 'frontRight', 'right'];
|
||||||
|
function parseLightBumper(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
const obj = {};
|
||||||
|
LIGHT_BUMPER_LABELS.forEach((label, idx) => {
|
||||||
|
obj[label] = Boolean(value & (1 << idx));
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateChecksum(frame, checksum) {
|
||||||
|
let sum = 0;
|
||||||
|
for (const byte of frame) {
|
||||||
|
sum = (sum + byte) & 0xff;
|
||||||
|
}
|
||||||
|
return (sum & 0xff) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseSensorFrame,
|
||||||
|
CHARGING_STATE,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
|
||||||
|
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||||
|
const payload = {
|
||||||
|
color: color || '#2196f3',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
ts,
|
||||||
|
};
|
||||||
|
io.emit('alert', payload);
|
||||||
|
io.emit('alert:new', { id: `${ts}-${Math.random().toString(36).slice(2)}`, ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendAlert,
|
||||||
|
};
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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, isLockdownAdmin } = require('./roleService');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
|
||||||
|
const socketRefs = new Map(); // socketId -> socket
|
||||||
|
const assignments = new Map(); // socketId -> roverId
|
||||||
|
const waiting = new Set(); // socketIds waiting for placement
|
||||||
|
const assignmentEvents = new EventEmitter();
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socketRefs.set(socket.id, socket);
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
socketRefs.delete(socket.id);
|
||||||
|
unassignSocket(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket, role }) => {
|
||||||
|
if (!socket || !socket.id) return;
|
||||||
|
if (role === 'user') {
|
||||||
|
assignSocket(socket);
|
||||||
|
} else {
|
||||||
|
unassignSocket(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modeEvents.on('change', (mode) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
|
||||||
|
if (locked) {
|
||||||
|
reassignFromRover(roverId);
|
||||||
|
} else {
|
||||||
|
reassignWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// avoid double assignment
|
||||||
|
if (assignments.has(socket.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = pickRover(socket);
|
||||||
|
if (!target) {
|
||||||
|
waiting.add(socket.id);
|
||||||
|
logger.info('No rover available, user waiting', socket.id);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
roverManager.requestControl(target.id, socket, { allowUser: true });
|
||||||
|
assignments.set(socket.id, target.id);
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
logger.info('Assigned user to rover', socket.id, target.id);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to assign user', err.message);
|
||||||
|
waiting.add(socket.id);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unassignSocket(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
const roverId = assignments.get(socket.id);
|
||||||
|
if (roverId) {
|
||||||
|
roverManager.releaseControl(roverId, socket);
|
||||||
|
assignments.delete(socket.id);
|
||||||
|
logger.info('Unassigned socket from rover', socket.id, roverId);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reassignFromRover(roverId) {
|
||||||
|
for (const [socketId, rid] of assignments.entries()) {
|
||||||
|
if (rid !== roverId) continue;
|
||||||
|
const socket = socketRefs.get(socketId);
|
||||||
|
if (!socket) {
|
||||||
|
assignments.delete(socketId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
roverManager.releaseControl(rid, socket);
|
||||||
|
assignments.delete(socketId);
|
||||||
|
assignSocket(socket);
|
||||||
|
assignmentEvents.emit('update', socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reassignWaiting() {
|
||||||
|
for (const socketId of Array.from(waiting)) {
|
||||||
|
const socket = socketRefs.get(socketId);
|
||||||
|
if (socket) {
|
||||||
|
assignSocket(socket);
|
||||||
|
} else {
|
||||||
|
waiting.delete(socketId);
|
||||||
|
}
|
||||||
|
assignmentEvents.emit('update', socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseAssignment(socket, roverId) {
|
||||||
|
roverManager.releaseControl(roverId, socket);
|
||||||
|
assignments.delete(socket.id);
|
||||||
|
waiting.add(socket.id);
|
||||||
|
logger.info('Released assignment back to queue', socket.id, roverId);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeAssignment(socketId) {
|
||||||
|
const assignedRoverId = assignments.get(socketId) || null;
|
||||||
|
const adminRoverId = assignedRoverId ? null : roverManager.getPrimaryRoverForSocket(socketId);
|
||||||
|
const waitingIndex = waiting.has(socketId) ? Array.from(waiting).indexOf(socketId) : -1;
|
||||||
|
const roverId = assignedRoverId || adminRoverId || null;
|
||||||
|
const waitingStatus = waiting.has(socketId);
|
||||||
|
return {
|
||||||
|
roverId,
|
||||||
|
status: assignedRoverId ? 'assigned' : adminRoverId ? 'admin' : waitingStatus ? 'waiting' : null,
|
||||||
|
queuePosition: waitingIndex >= 0 ? waitingIndex + 1 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
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();
|
||||||
|
const admins = config.admins || [];
|
||||||
|
|
||||||
|
function findAdmin(username) {
|
||||||
|
return admins.find((admin) => admin.username === username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(username, password) {
|
||||||
|
const admin = findAdmin(username);
|
||||||
|
if (!admin) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
const ok = await bcrypt.compare(password, admin.password_hash);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(socket) {
|
||||||
|
return socket?.data?.role === 'admin' || socket?.data?.role === 'lockdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLockdownAdmin(socket) {
|
||||||
|
return socket?.data?.role === 'lockdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const requestedRole = socket.handshake?.query?.role;
|
||||||
|
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
||||||
|
setRole(socket, initialRole);
|
||||||
|
logger.info('Socket connected with role', socket.id, initialRole);
|
||||||
|
socket.emit('auth:role', { role: initialRole });
|
||||||
|
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);
|
||||||
|
socket.emit('auth:role', { role });
|
||||||
|
clearLockdownTimer(socket);
|
||||||
|
logger.info('Login success', socket.id, role);
|
||||||
|
cb({ success: true, role: socket.data.role });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Login failed', socket.id, err.message);
|
||||||
|
cb({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleRoleChange({ role } = {}, cb = () => {}) {
|
||||||
|
if (role === 'spectator' || role === 'user') {
|
||||||
|
setRole(socket, role);
|
||||||
|
socket.emit('auth:role', { role });
|
||||||
|
logger.info('Role changed via client request', socket.id, role);
|
||||||
|
cb({ success: true, role });
|
||||||
|
} else {
|
||||||
|
cb({ error: 'Invalid role' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('role:set', handleRoleChange);
|
||||||
|
socket.on('session:setRole', handleRoleChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isAdmin,
|
||||||
|
isLockdownAdmin,
|
||||||
|
authenticate,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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);
|
||||||
|
if (!record || !record.ws) {
|
||||||
|
throw new Error('Rover offline');
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
const message = { ...payload, id };
|
||||||
|
record.ws.send(JSON.stringify(message));
|
||||||
|
pendingCommands.set(id, { roverId, ts: Date.now(), type: payload.type });
|
||||||
|
logger.info('Issued command', roverId, payload.type, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAck(msg) {
|
||||||
|
const pending = pendingCommands.get(msg.id);
|
||||||
|
if (!pending) return;
|
||||||
|
pendingCommands.delete(msg.id);
|
||||||
|
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
||||||
|
io.emit('commandAck', {
|
||||||
|
roverId: pending.roverId,
|
||||||
|
id: msg.id,
|
||||||
|
status: msg.status || 'ok',
|
||||||
|
error: msg.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
function handleCommand({ roverId, type, data } = {}, cb) {
|
||||||
|
const reply = typeof cb === 'function' ? cb : () => {};
|
||||||
|
try {
|
||||||
|
if (!roverId) {
|
||||||
|
throw new Error('roverId required');
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
reply({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const { httpServer } = require('../globals/http');
|
||||||
|
const config = require('../globals/config');
|
||||||
|
const logger = require('../globals/logger').child('httpServer');
|
||||||
|
|
||||||
|
httpServer.listen(config.port, () => {
|
||||||
|
logger.info(`Server listening on :${config.port}`);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user