mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
494
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8bb8a0cf | ||
|
|
87936510b2 | ||
|
|
66b8c839b7 | ||
|
|
7041a3c6df | ||
|
|
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 |
@@ -11,3 +11,7 @@ config.h
|
|||||||
robots.json
|
robots.json
|
||||||
roverd-dummy
|
roverd-dummy
|
||||||
server/config.yaml
|
server/config.yaml
|
||||||
|
package-lock.json
|
||||||
|
server/data/discord-guilds.json
|
||||||
|
server/data/community-goal.json
|
||||||
|
server/data
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ The script must be executed via `sudo` from the user that owns the repo. It will
|
|||||||
|
|
||||||
Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically.
|
Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically.
|
||||||
|
|
||||||
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (`http://192.168.0.86:8889`). The client automatically requests `http://<base>/<rover-id>/whep`, so you never have to embed IDs or path templates. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer.
|
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (set it to the URL you expose publicly, e.g. `https://rover.otter.land/video`). If your proxy can’t rewrite paths, create the `/video` location there and add a custom nginx snippet to rewrite `/video/<rover>/whep` to `/<>/whep` before forwarding to mediaMTX. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer.
|
||||||
|
|
||||||
|
Room cameras now use JPEG snapshots (4 fps) instead of WHEP. Each entry in `roomCameras` must include a `url` pointing at the snapshot endpoint; the server polls and relays frames over socket.io with the same access rules as before.
|
||||||
|
|
||||||
### Video handshake + diagnostics
|
### Video handshake + diagnostics
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
#configuration for roverd
|
#configuration for roverd
|
||||||
name: dummy1
|
name: dummy1
|
||||||
serverUrl: ws://192.168.0.84:8080/rover
|
serverUrl: ws://127.0.0.1:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyS0
|
device: /dev/ttyS0
|
||||||
baud: 115200
|
baud: 115200
|
||||||
@@ -18,3 +18,5 @@ media:
|
|||||||
service: mediamtx.service
|
service: mediamtx.service
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
|
nightVision:
|
||||||
|
enabled: false
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -60,7 +60,7 @@ Flags:
|
|||||||
| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) |
|
| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) |
|
||||||
| `-c PATH` | seed `/etc/roverd.yaml` from another template |
|
| `-c PATH` | seed `/etc/roverd.yaml` from another template |
|
||||||
|
|
||||||
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish…` (the host comes from `serverUrl`).
|
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish…` (the host comes from `serverUrl`). If your reverse proxy adds prefixes (like `/video/<name>`), use its rewrite options so the rover keeps publishing to plain `<name>`.
|
||||||
|
|
||||||
Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites the roverd + video-publisher binaries and drops the latest systemd units so the only configuration you ever touch manually is `/etc/roverd.yaml`. `roverd` rewrites `/var/lib/roverd/video.env` on startup, so no other files need editing.
|
Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites the roverd + video-publisher binaries and drops the latest systemd units so the only configuration you ever touch manually is `/etc/roverd.yaml`. `roverd` rewrites `/var/lib/roverd/video.env` on startup, so no other files need editing.
|
||||||
## Manual installation
|
## Manual installation
|
||||||
@@ -71,7 +71,7 @@ Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites
|
|||||||
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
|
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
|
||||||
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
|
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
|
||||||
```
|
```
|
||||||
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`.
|
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`; keep proxy-only path prefixes out of this URL so every rover keeps the same simple stream name.
|
||||||
|
|
||||||
2. Install the systemd unit:
|
2. Install the systemd unit:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
||||||
|
options snd_rpi_googlevoicehat_soundcard index=0
|
||||||
|
|
||||||
|
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
|
||||||
|
pcm.softvol {
|
||||||
|
type softvol
|
||||||
|
slave.pcm "plughw:0,0"
|
||||||
|
control {
|
||||||
|
name "SoftMaster"
|
||||||
|
card 0
|
||||||
|
}
|
||||||
|
min_dB -51.0
|
||||||
|
max_dB 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defaults: playback through softvol, capture raw on the HAT
|
||||||
|
pcm.!default {
|
||||||
|
type asym
|
||||||
|
playback.pcm "softvol"
|
||||||
|
capture.pcm "hw:0,0"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctl.!default {
|
||||||
|
type hw
|
||||||
|
card 0
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# Disable history expansion so PUBLISH_URL values with "!" are safe when sourcing env files.
|
||||||
|
set +H
|
||||||
|
|
||||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||||
|
|
||||||
@@ -10,13 +12,14 @@ fi
|
|||||||
|
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
source "$ENV_FILE"
|
source "$ENV_FILE"
|
||||||
|
|
||||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
||||||
|
|
||||||
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
|
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
|
||||||
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
||||||
VIDEO_FPS="${VIDEO_FPS:-30}"
|
VIDEO_FPS="${VIDEO_FPS:-30}"
|
||||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||||
|
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
||||||
|
FLIP_ARGS=(--rotation 180)
|
||||||
|
|
||||||
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
||||||
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
||||||
@@ -44,12 +47,18 @@ run_pipeline() {
|
|||||||
--timeout 0 \
|
--timeout 0 \
|
||||||
--width "${VIDEO_WIDTH}" \
|
--width "${VIDEO_WIDTH}" \
|
||||||
--height "${VIDEO_HEIGHT}" \
|
--height "${VIDEO_HEIGHT}" \
|
||||||
|
"${FLIP_ARGS[@]}" \
|
||||||
--framerate "${VIDEO_FPS}" \
|
--framerate "${VIDEO_FPS}" \
|
||||||
--bitrate "${VIDEO_BITRATE}" \
|
--bitrate "${VIDEO_BITRATE}" \
|
||||||
--codec h264 \
|
--codec h264 \
|
||||||
--profile baseline \
|
--profile baseline \
|
||||||
--denoise cdn_off \
|
--denoise auto \
|
||||||
--nopreview \
|
--nopreview \
|
||||||
|
--metering centre \
|
||||||
|
--ev 0.1 \
|
||||||
|
--awb auto \
|
||||||
|
--saturation 0.6 \
|
||||||
|
--brightness 0 \
|
||||||
--output - \
|
--output - \
|
||||||
| "${FFMPEG_BIN_PATH}" \
|
| "${FFMPEG_BIN_PATH}" \
|
||||||
-hide_banner \
|
-hide_banner \
|
||||||
|
|||||||
+124
-9
@@ -94,7 +94,39 @@ install_video_deps() {
|
|||||||
apt-get install -y --no-install-recommends libcamera-apps ffmpeg
|
apt-get install -y --no-install-recommends libcamera-apps ffmpeg
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_user roverd "dialout,gpio,video,render"
|
find_boot_config() {
|
||||||
|
if [[ -f /boot/firmware/config.txt ]]; then
|
||||||
|
printf "/boot/firmware/config.txt"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f /boot/config.txt ]]; then
|
||||||
|
printf "/boot/config.txt"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_pwm_overlay() {
|
||||||
|
local boot_config
|
||||||
|
if ! boot_config="$(find_boot_config)"; then
|
||||||
|
log "WARNING: unable to locate /boot config.txt; please ensure dtoverlay=pwm-2chan is added manually for servo support"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if grep -Eq '^\s*dtoverlay=pwm(-2chan)?' "$boot_config"; then
|
||||||
|
log "PWM overlay already present in $boot_config"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
|
||||||
|
cp "$boot_config" "$backup"
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Added by roverd installer to expose PWM hardware for camera servo control on GPIO12/13 (leaves GPIO18/19 free for I2S)"
|
||||||
|
echo "dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4"
|
||||||
|
} >> "$boot_config"
|
||||||
|
log "Enabled dtoverlay=pwm-2chan on GPIO12/13 in $boot_config (backup at $backup). Reboot required for changes to apply."
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_user roverd "dialout,gpio,video,render,audio"
|
||||||
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
|
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
|
||||||
log "Installed roverd binary"
|
log "Installed roverd binary"
|
||||||
|
|
||||||
@@ -112,33 +144,116 @@ install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
|
|||||||
log "Installed roverd systemd unit"
|
log "Installed roverd systemd unit"
|
||||||
|
|
||||||
install_video_deps
|
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 video publisher assets
|
||||||
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
|
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
|
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 -d -o roverd -g roverd /var/lib/roverd
|
install -d -o roverd -g roverd /var/lib/roverd
|
||||||
if [[ ! -f /var/lib/roverd/video.env ]]; then
|
cat > /var/lib/roverd/video.env <<'ENV'
|
||||||
cat > /var/lib/roverd/video.env <<'ENV'
|
|
||||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316
|
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
|
||||||
VIDEO_WIDTH=1280
|
VIDEO_WIDTH=1280
|
||||||
VIDEO_HEIGHT=720
|
VIDEO_HEIGHT=720
|
||||||
VIDEO_FPS=30
|
VIDEO_FPS=30
|
||||||
VIDEO_BITRATE=3000000
|
VIDEO_BITRATE=2000000
|
||||||
|
AUDIO_ENABLE=0
|
||||||
|
AUDIO_DEVICE=hw:0,0
|
||||||
|
AUDIO_RATE=48000
|
||||||
|
AUDIO_CHANNELS=2
|
||||||
ENV
|
ENV
|
||||||
chown roverd:roverd /var/lib/roverd/video.env
|
chown roverd:roverd /var/lib/roverd/video.env
|
||||||
chmod 0640 /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
|
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 daemon-reload
|
||||||
systemctl enable roverd.service
|
systemctl enable roverd.service
|
||||||
systemctl enable video-publisher.service
|
systemctl enable video-publisher.service
|
||||||
|
systemctl enable audio-only-publisher.service
|
||||||
if [[ $CONFIG_EXISTS -eq 1 ]]; then
|
if [[ $CONFIG_EXISTS -eq 1 ]]; then
|
||||||
systemctl restart roverd.service
|
systemctl restart roverd.service
|
||||||
systemctl restart video-publisher.service
|
systemctl restart video-publisher.service
|
||||||
log "Restarted roverd + video publisher"
|
systemctl restart audio-only-publisher.service
|
||||||
|
log "Restarted roverd + video/audio publishers"
|
||||||
else
|
else
|
||||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher"
|
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Install complete"
|
log "Install complete"
|
||||||
|
|||||||
+2
-1
@@ -10,9 +10,10 @@ build:
|
|||||||
|
|
||||||
pi-build:
|
pi-build:
|
||||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
|
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
|
||||||
|
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
|
||||||
|
|
||||||
dummy:
|
dummy:
|
||||||
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
|
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BIN_DIR)/roverd
|
rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//go:build !dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
rpio "github.com/stianeikeland/go-rpio/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CameraServo struct {
|
||||||
|
cfg CameraServoConfig
|
||||||
|
logger *log.Logger
|
||||||
|
pin rpio.Pin
|
||||||
|
mu sync.Mutex
|
||||||
|
currentAngle float64
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
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.applyPulseLocked(s.angleToPulse(clamped))
|
||||||
|
s.currentAngle = clamped
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
s.applyPulseLocked(micros)
|
||||||
|
s.currentAngle = s.pulseToAngle(micros)
|
||||||
|
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) 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
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load config: %v", err)
|
log.Fatalf("load config: %v", err)
|
||||||
}
|
}
|
||||||
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil {
|
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
|
||||||
log.Fatalf("prepare media env: %v", err)
|
log.Fatalf("prepare media env: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,15 +55,33 @@ func main() {
|
|||||||
|
|
||||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
||||||
|
|
||||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
|
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
|
||||||
if mediaSupervisor != nil {
|
if mediaSupervisor != nil {
|
||||||
mediaSupervisor.Start(ctx)
|
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)
|
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
||||||
go autoCharge.Run(ctx, sensorSamples)
|
go autoCharge.Run(ctx, sensorSamples)
|
||||||
|
|
||||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, logger)
|
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
|
||||||
|
|
||||||
retryDelay := time.Second
|
retryDelay := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
rpio "github.com/stianeikeland/go-rpio/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
pinNum = flag.Int("pin", 19, "BCM pin connected to the servo signal line")
|
||||||
|
freqHz = flag.Int("freq", 50, "Servo PWM frequency in Hz")
|
||||||
|
cycleLen = flag.Int("cycle", 20000, "PWM cycle length (counts per period)")
|
||||||
|
minPulse = flag.Int("min", 900, "Minimum pulse width in microseconds")
|
||||||
|
maxPulse = flag.Int("max", 2100, "Maximum pulse width in microseconds")
|
||||||
|
stepPulse = flag.Int("step", 100, "Pulse width increment in microseconds when sweeping")
|
||||||
|
sweeps = flag.Int("sweeps", 2, "How many full min→max→min sweeps to perform")
|
||||||
|
pause = flag.Duration("pause", 150*time.Millisecond, "Delay between pulse adjustments")
|
||||||
|
holdPulse = flag.Int("hold", 0, "Pulse width to hold before exiting (0 = midpoint of min/max)")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *freqHz <= 0 || *cycleLen <= 0 {
|
||||||
|
log.Fatalf("invalid freq (%d) or cycle (%d)", *freqHz, *cycleLen)
|
||||||
|
}
|
||||||
|
if *minPulse <= 0 || *maxPulse <= 0 || *minPulse >= *maxPulse {
|
||||||
|
log.Fatalf("invalid min/max pulses (%d/%d)", *minPulse, *maxPulse)
|
||||||
|
}
|
||||||
|
if *stepPulse <= 0 {
|
||||||
|
log.Fatalf("step must be > 0 (got %d)", *stepPulse)
|
||||||
|
}
|
||||||
|
if *pause <= 0 {
|
||||||
|
log.Fatalf("pause must be > 0 (got %s)", pause)
|
||||||
|
}
|
||||||
|
if *sweeps < 0 {
|
||||||
|
log.Fatalf("sweeps must be >= 0 (got %d)", *sweeps)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rpio.Open(); err != nil {
|
||||||
|
log.Fatalf("open gpio: %v", err)
|
||||||
|
}
|
||||||
|
defer rpio.Close()
|
||||||
|
|
||||||
|
pin := rpio.Pin(*pinNum)
|
||||||
|
pin.Mode(rpio.Pwm)
|
||||||
|
|
||||||
|
targetClock := *freqHz * *cycleLen
|
||||||
|
pin.Freq(targetClock)
|
||||||
|
log.Printf("Configured PWM pin %d at %d Hz (clock=%d Hz, cycle=%d)", *pinNum, *freqHz, targetClock, *cycleLen)
|
||||||
|
|
||||||
|
setPulse := func(us int) {
|
||||||
|
clamped := clamp(us, *minPulse, *maxPulse)
|
||||||
|
pin.DutyCycle(uint32(clamped), uint32(*cycleLen))
|
||||||
|
log.Printf("pulse -> %dµs", clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
mid := (*minPulse + *maxPulse) / 2
|
||||||
|
setPulse(mid)
|
||||||
|
|
||||||
|
runSweep := func() {
|
||||||
|
for pulse := *minPulse; pulse <= *maxPulse; pulse += *stepPulse {
|
||||||
|
setPulse(pulse)
|
||||||
|
time.Sleep(*pause)
|
||||||
|
}
|
||||||
|
for pulse := *maxPulse - *stepPulse; pulse >= *minPulse; pulse -= *stepPulse {
|
||||||
|
setPulse(pulse)
|
||||||
|
time.Sleep(*pause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < *sweeps; i++ {
|
||||||
|
log.Printf("Sweep %d/%d", i+1, *sweeps)
|
||||||
|
runSweep()
|
||||||
|
}
|
||||||
|
|
||||||
|
finalPulse := *holdPulse
|
||||||
|
if finalPulse <= 0 {
|
||||||
|
finalPulse = mid
|
||||||
|
}
|
||||||
|
setPulse(finalPulse)
|
||||||
|
log.Printf("Holding at %dµs", clamp(finalPulse, *minPulse, *maxPulse))
|
||||||
|
log.Print("Servo verification complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(value, min, max int) int {
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ type helloMessage struct {
|
|||||||
Battery BatteryConfig `json:"battery"`
|
Battery BatteryConfig `json:"battery"`
|
||||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||||
Media MediaConfig `json:"media"`
|
Media MediaConfig `json:"media"`
|
||||||
|
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||||
|
Audio AudioConfig `json:"audio"`
|
||||||
|
NightVision NightVisionConfig `json:"nightVision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sensorMessage struct {
|
type sensorMessage struct {
|
||||||
@@ -22,6 +25,10 @@ type inboundMessage struct {
|
|||||||
Raw string `json:"raw,omitempty"`
|
Raw string `json:"raw,omitempty"`
|
||||||
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
|
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
|
||||||
Media *mediaCommand `json:"media,omitempty"`
|
Media *mediaCommand `json:"media,omitempty"`
|
||||||
|
Servo *servoPayload `json:"servo,omitempty"`
|
||||||
|
TTS *ttsPayload `json:"tts,omitempty"`
|
||||||
|
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||||
|
Song *songPayload `json:"song,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
type driveDirectPayload struct {
|
||||||
@@ -43,6 +50,35 @@ type mediaCommand struct {
|
|||||||
Action string `json:"action"`
|
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 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 ackMessage struct {
|
type ackMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
+169
-7
@@ -53,11 +53,26 @@ type BatteryConfig struct {
|
|||||||
Urgent int `yaml:"urgent"`
|
Urgent int `yaml:"urgent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AudioConfig struct {
|
||||||
|
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
||||||
|
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,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 MediaConfig struct {
|
type MediaConfig struct {
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||||
|
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||||
PublishPort int `yaml:"publishPort" json:"-"`
|
PublishPort int `yaml:"publishPort" json:"-"`
|
||||||
Manage bool `yaml:"manage"`
|
Manage bool `yaml:"manage"`
|
||||||
|
ManageAudio bool `yaml:"manageAudio"`
|
||||||
Service string `yaml:"service"`
|
Service string `yaml:"service"`
|
||||||
|
AudioService string `yaml:"audioService"`
|
||||||
HealthURL string `yaml:"healthUrl"`
|
HealthURL string `yaml:"healthUrl"`
|
||||||
HealthInterval Duration `yaml:"healthInterval"`
|
HealthInterval Duration `yaml:"healthInterval"`
|
||||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
VideoWidth int `yaml:"videoWidth" json:"-"`
|
||||||
@@ -66,6 +81,28 @@ type MediaConfig struct {
|
|||||||
VideoBitrate int `yaml:"videoBitrate" 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 Config struct {
|
type Config struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
ServerURL string `yaml:"serverUrl"`
|
ServerURL string `yaml:"serverUrl"`
|
||||||
@@ -74,6 +111,9 @@ type Config struct {
|
|||||||
Battery BatteryConfig `yaml:"battery"`
|
Battery BatteryConfig `yaml:"battery"`
|
||||||
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
||||||
Media MediaConfig `yaml:"media"`
|
Media MediaConfig `yaml:"media"`
|
||||||
|
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||||
|
Audio AudioConfig `yaml:"audio"`
|
||||||
|
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
@@ -84,7 +124,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
cfg := Config{
|
cfg := Config{
|
||||||
MaxWheelMMs: 500,
|
MaxWheelMMs: 500,
|
||||||
BRC: BRCConfig{
|
BRC: BRCConfig{
|
||||||
GPIOPin: -1,
|
GPIOPin: 4,
|
||||||
GPIOChip: "gpiochip0",
|
GPIOChip: "gpiochip0",
|
||||||
PulseEvery: Duration{
|
PulseEvery: Duration{
|
||||||
Duration: time.Minute,
|
Duration: time.Minute,
|
||||||
@@ -99,7 +139,35 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
VideoWidth: 1280,
|
VideoWidth: 1280,
|
||||||
VideoHeight: 720,
|
VideoHeight: 720,
|
||||||
VideoFPS: 30,
|
VideoFPS: 30,
|
||||||
VideoBitrate: 3000000,
|
VideoBitrate: 2000000,
|
||||||
|
},
|
||||||
|
CameraServo: CameraServoConfig{
|
||||||
|
Pin: 12,
|
||||||
|
FreqHz: 50,
|
||||||
|
CycleLen: 20000,
|
||||||
|
MinPulseUs: 900,
|
||||||
|
MaxPulseUs: 2100,
|
||||||
|
MinAngle: -15,
|
||||||
|
MaxAngle: 30,
|
||||||
|
HomeAngle: 0,
|
||||||
|
NudgeDegrees: 2,
|
||||||
|
},
|
||||||
|
Audio: AudioConfig{
|
||||||
|
CaptureEnabled: false,
|
||||||
|
CaptureDevice: "rovermic",
|
||||||
|
SampleRate: 48000,
|
||||||
|
Channels: 2,
|
||||||
|
Bitrate: 24000,
|
||||||
|
TTSEnabled: false,
|
||||||
|
DefaultEngine: "flite",
|
||||||
|
DefaultVoice: "rms",
|
||||||
|
DefaultPitch: 50,
|
||||||
|
},
|
||||||
|
NightVision: NightVisionConfig{
|
||||||
|
Enabled: true,
|
||||||
|
GPIOPin: 22,
|
||||||
|
GPIOChip: "gpiochip0",
|
||||||
|
InitialOn: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
@@ -151,12 +219,106 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.Media.PublishURL = derived
|
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 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)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func derivePublishURL(serverURL, roverName string, port int) (string, error) {
|
func validateServoConfig(cfg *CameraServoConfig) error {
|
||||||
if roverName == "" {
|
if !cfg.Enabled {
|
||||||
return "", errors.New("missing rover name for publishUrl")
|
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.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 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 derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||||
|
if streamName == "" {
|
||||||
|
return "", errors.New("missing stream name for publishUrl")
|
||||||
}
|
}
|
||||||
parsed, err := url.Parse(serverURL)
|
parsed, err := url.Parse(serverURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,6 +331,6 @@ func derivePublishURL(serverURL, roverName string, port int) (string, error) {
|
|||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
port = 9000
|
port = 9000
|
||||||
}
|
}
|
||||||
streamName := url.PathEscape(roverName)
|
escaped := url.PathEscape(streamName)
|
||||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316", host, port, streamName), nil
|
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module multiroombarover/pi/roverd
|
|||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/stianeikeland/go-rpio/v4 v4.6.0
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||||
github.com/warthog618/go-gpiocdev v0.9.1
|
github.com/warthog618/go-gpiocdev v0.9.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
|
||||||
|
|||||||
+35
-8
@@ -9,24 +9,51 @@ import (
|
|||||||
|
|
||||||
const publisherEnvPath = "/var/lib/roverd/video.env"
|
const publisherEnvPath = "/var/lib/roverd/video.env"
|
||||||
|
|
||||||
func UpdatePublisherEnv(cfg MediaConfig) error {
|
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||||
if cfg.PublishURL == "" {
|
if media.PublishURL == "" {
|
||||||
return fmt.Errorf("media publishUrl missing")
|
return fmt.Errorf("media publishUrl missing")
|
||||||
}
|
}
|
||||||
if cfg.VideoWidth <= 0 || cfg.VideoHeight <= 0 || cfg.VideoFPS <= 0 || cfg.VideoBitrate <= 0 {
|
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||||
|
return fmt.Errorf("audio publishUrl missing")
|
||||||
|
}
|
||||||
|
if media.VideoWidth <= 0 || media.VideoHeight <= 0 || media.VideoFPS <= 0 || media.VideoBitrate <= 0 {
|
||||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", cfg.PublishURL)
|
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth)
|
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight)
|
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS)
|
}
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate)
|
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||||
|
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
||||||
|
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)
|
||||||
|
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 {
|
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolToInt(v bool) int {
|
||||||
|
if v {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import (
|
|||||||
|
|
||||||
type MediaSupervisor struct {
|
type MediaSupervisor struct {
|
||||||
cfg MediaConfig
|
cfg MediaConfig
|
||||||
|
audio AudioConfig
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
checkInterval time.Duration
|
checkInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
|
||||||
if err := UpdatePublisherEnv(cfg); err != nil {
|
if err := UpdatePublisherEnv(cfg, audio); err != nil {
|
||||||
logger.Printf("media supervisor: update env failed: %v", err)
|
logger.Printf("media supervisor: update env failed: %v", err)
|
||||||
}
|
}
|
||||||
if !cfg.Manage || cfg.Service == "" {
|
if !cfg.Manage || cfg.Service == "" {
|
||||||
@@ -35,6 +36,7 @@ func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
|||||||
}
|
}
|
||||||
return &MediaSupervisor{
|
return &MediaSupervisor{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
audio: audio,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: client,
|
client: client,
|
||||||
checkInterval: interval,
|
checkInterval: interval,
|
||||||
@@ -45,7 +47,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||||
m.logger.Printf("media supervisor: update env failed: %v", err)
|
m.logger.Printf("media supervisor: update env failed: %v", err)
|
||||||
}
|
}
|
||||||
if m.cfg.HealthURL == "" || m.client == nil {
|
if m.cfg.HealthURL == "" || m.client == nil {
|
||||||
@@ -76,7 +78,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return errors.New("media supervisor disabled")
|
return errors.New("media supervisor disabled")
|
||||||
}
|
}
|
||||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
switch action {
|
switch action {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//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) 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,20 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ serial:
|
|||||||
device: /dev/ttyAMA0
|
device: /dev/ttyAMA0
|
||||||
baud: 115200
|
baud: 115200
|
||||||
brc:
|
brc:
|
||||||
gpioPin: 17
|
gpioPin: 4
|
||||||
gpioChip: gpiochip0
|
gpioChip: gpiochip0
|
||||||
pulseEvery: 1m
|
pulseEvery: 1m
|
||||||
pulseWidth: 1s
|
pulseWidth: 1s
|
||||||
@@ -15,13 +15,41 @@ battery:
|
|||||||
urgent: 1650
|
urgent: 1650
|
||||||
maxWheelSpeed: 350
|
maxWheelSpeed: 350
|
||||||
media:
|
media:
|
||||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316
|
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
publishPort: 9000
|
publishPort: 9000
|
||||||
videoWidth: 1280
|
videoWidth: 1280
|
||||||
videoHeight: 720
|
videoHeight: 720
|
||||||
videoFps: 30
|
videoFps: 30
|
||||||
videoBitrate: 3000000
|
videoBitrate: 2000000
|
||||||
manage: true
|
manage: true
|
||||||
service: video-publisher.service
|
service: video-publisher.service
|
||||||
healthUrl: ""
|
healthUrl: ""
|
||||||
healthInterval: 30s
|
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
|
||||||
|
sampleRate: 48000
|
||||||
|
channels: 2
|
||||||
|
bitrate: 24000
|
||||||
|
ttsEnabled: false
|
||||||
|
defaultEngine: flite
|
||||||
|
defaultVoice: rms
|
||||||
|
defaultPitch: 50
|
||||||
|
nightVision:
|
||||||
|
enabled: true
|
||||||
|
gpioPin: 22
|
||||||
|
gpioChip: gpiochip0
|
||||||
|
initialOn: true
|
||||||
|
|||||||
+13
-1
@@ -5,7 +5,7 @@ serial:
|
|||||||
device: /dev/ttyAMA0
|
device: /dev/ttyAMA0
|
||||||
baud: 115200
|
baud: 115200
|
||||||
brc:
|
brc:
|
||||||
gpioPin: 17
|
gpioPin: 4
|
||||||
gpioChip: gpiochip0
|
gpioChip: gpiochip0
|
||||||
pulseEvery: 1m
|
pulseEvery: 1m
|
||||||
pulseWidth: 1s
|
pulseWidth: 1s
|
||||||
@@ -19,3 +19,15 @@ media:
|
|||||||
service: mediamtx.service
|
service: mediamtx.service
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||||
healthInterval: 30s
|
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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
sensorHeader = 19
|
sensorHeader = 19
|
||||||
sensorReadTimeout = 150 * time.Millisecond
|
sensorReadTimeout = 150 * time.Millisecond
|
||||||
|
sensorThrottleMinimum = 50 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
type SensorStreamer struct {
|
type SensorStreamer struct {
|
||||||
@@ -29,6 +30,7 @@ func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSa
|
|||||||
|
|
||||||
func (s *SensorStreamer) Run(ctx context.Context) {
|
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||||
reader := bufio.NewReader(s.r)
|
reader := bufio.NewReader(s.r)
|
||||||
|
var nextSend time.Time
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -62,6 +64,12 @@ func (s *SensorStreamer) Run(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if !nextSend.IsZero() && now.Before(nextSend) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextSend = now.Add(sensorThrottleMinimum)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case s.rawOut <- frame:
|
case s.rawOut <- frame:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -92,6 +92,33 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
|||||||
return s.write(raw)
|
return s.write(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) StartOI() error {
|
||||||
|
return s.write([]byte{128})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
func (s *SerialAdapter) SeekDock() error {
|
||||||
return s.write([]byte{143})
|
return s.write([]byte{143})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||||
|
if len(notes) == 0 {
|
||||||
|
return fmt.Errorf("song requires at least one note")
|
||||||
|
}
|
||||||
|
if len(notes) > 16 {
|
||||||
|
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
|
||||||
|
}
|
||||||
|
if slot < 0 || slot > 4 {
|
||||||
|
return fmt.Errorf("song slot must be 0-4")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte{140, byte(slot), byte(len(notes))}
|
||||||
|
for _, n := range notes {
|
||||||
|
note := clampInt(n.Note, 31, 127)
|
||||||
|
duration := clampInt(n.Duration, 1, 255)
|
||||||
|
payload = append(payload, byte(note), byte(duration))
|
||||||
|
}
|
||||||
|
if err := s.write(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.write([]byte{141, byte(slot)})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,17 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) StartOI() error {
|
||||||
|
s.log.Printf("[dummy] start OI")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
func (s *SerialAdapter) SeekDock() error {
|
||||||
s.log.Printf("[dummy] seek dock")
|
s.log.Printf("[dummy] seek dock")
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
+229
-9
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
@@ -15,28 +16,47 @@ type WSClient struct {
|
|||||||
cfg *Config
|
cfg *Config
|
||||||
adapter *SerialAdapter
|
adapter *SerialAdapter
|
||||||
sensorFrames <-chan []byte
|
sensorFrames <-chan []byte
|
||||||
events <-chan RoverEvent
|
events chan RoverEvent
|
||||||
media *MediaSupervisor
|
media *MediaSupervisor
|
||||||
|
servo *CameraServo
|
||||||
|
nightVision *NightVisionLight
|
||||||
log *log.Logger
|
log *log.Logger
|
||||||
|
recoverMu sync.Mutex
|
||||||
|
recovering bool
|
||||||
|
ttsQueue chan *ttsPayload
|
||||||
|
connMu sync.Mutex
|
||||||
|
connected bool
|
||||||
|
disconnectT *time.Timer
|
||||||
|
seekIssued bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, logger *log.Logger) *WSClient {
|
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||||
|
var ttsQueue chan *ttsPayload
|
||||||
|
if cfg.Audio.TTSEnabled {
|
||||||
|
ttsQueue = make(chan *ttsPayload, 2)
|
||||||
|
}
|
||||||
return &WSClient{
|
return &WSClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
sensorFrames: frames,
|
sensorFrames: frames,
|
||||||
events: events,
|
events: events,
|
||||||
media: media,
|
media: media,
|
||||||
|
servo: servo,
|
||||||
|
nightVision: nightVision,
|
||||||
log: logger,
|
log: logger,
|
||||||
|
ttsQueue: ttsQueue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) Run(ctx context.Context) error {
|
func (c *WSClient) Run(ctx context.Context) error {
|
||||||
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
|
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.markDisconnected()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.markConnected()
|
||||||
defer conn.Close(websocket.StatusInternalError, "closed")
|
defer conn.Close(websocket.StatusInternalError, "closed")
|
||||||
|
defer c.markDisconnected()
|
||||||
|
|
||||||
if err := c.sendHello(ctx, conn); err != nil {
|
if err := c.sendHello(ctx, conn); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -46,6 +66,7 @@ func (c *WSClient) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
c.startTTSWorker(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
errCh <- c.readLoop(ctx, conn)
|
errCh <- c.readLoop(ctx, conn)
|
||||||
}()
|
}()
|
||||||
@@ -68,7 +89,11 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
|||||||
Battery: c.cfg.Battery,
|
Battery: c.cfg.Battery,
|
||||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||||
Media: c.cfg.Media,
|
Media: c.cfg.Media,
|
||||||
|
CameraServo: c.cfg.CameraServo,
|
||||||
|
Audio: c.cfg.Audio,
|
||||||
|
NightVision: c.cfg.NightVision,
|
||||||
}
|
}
|
||||||
|
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
||||||
return writeJSON(ctx, conn, msg)
|
return writeJSON(ctx, conn, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +143,9 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
return c.adapter.MotorPWM(main, side, vac)
|
return c.adapter.MotorPWM(main, side, vac)
|
||||||
case msg.SensorStream != nil:
|
case msg.SensorStream != nil:
|
||||||
if msg.SensorStream.Enable {
|
if msg.SensorStream.Enable {
|
||||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
return c.adapter.StartSensorStream(defaultStreamPackets)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return c.adapter.PauseSensorStream(false)
|
return nil
|
||||||
}
|
|
||||||
return c.adapter.PauseSensorStream(true)
|
|
||||||
case msg.Raw != "" && len(msg.Raw) > 0:
|
case msg.Raw != "" && len(msg.Raw) > 0:
|
||||||
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,17 +163,121 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
return fmt.Errorf("media supervisor disabled")
|
return fmt.Errorf("media supervisor disabled")
|
||||||
}
|
}
|
||||||
return c.media.HandleAction(ctx, msg.Media.Action)
|
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.NightVision != nil:
|
||||||
|
if c.nightVision == nil {
|
||||||
|
return fmt.Errorf("night vision disabled")
|
||||||
|
}
|
||||||
|
return c.nightVision.HandleAction(msg.NightVision.Action)
|
||||||
|
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)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
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:
|
case frame := <-c.sensorFrames:
|
||||||
|
lastFrame = time.Now()
|
||||||
|
resetTimer()
|
||||||
msg := sensorMessage{
|
msg := sensorMessage{
|
||||||
Type: "sensor",
|
Type: "sensor",
|
||||||
Timestamp: time.Now().UnixMilli(),
|
Timestamp: time.Now().UnixMilli(),
|
||||||
@@ -185,6 +311,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) emitEvent(event string, data map[string]any) {
|
||||||
|
if c.events == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.events <- RoverEvent{
|
||||||
|
Type: "event",
|
||||||
|
Event: event,
|
||||||
|
Ts: time.Now().UnixMilli(),
|
||||||
|
Data: data,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||||
data, err := json.Marshal(v)
|
data, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,7 +348,86 @@ func (c *WSClient) ensureSensorStream() error {
|
|||||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.adapter.PauseSensorStream(false)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnectSeekDelay = time.Minute
|
||||||
|
|
||||||
|
func (c *WSClient) markConnected() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
c.connected = true
|
||||||
|
c.seekIssued = false
|
||||||
|
if c.disconnectT != nil {
|
||||||
|
c.disconnectT.Stop()
|
||||||
|
c.disconnectT = 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)
|
||||||
|
}
|
||||||
|
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) 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 {
|
func isModeOpcode(op byte) bool {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Rover Audio Publisher (ALSA -> SRT)
|
||||||
|
After=network-online.target roverd.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=roverd
|
||||||
|
Group=roverd
|
||||||
|
EnvironmentFile=/var/lib/roverd/video.env
|
||||||
|
ExecStart=/usr/local/bin/audio-only-publisher
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=5
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -8,8 +8,6 @@ Type=simple
|
|||||||
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
|
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
User=roverd
|
|
||||||
Group=roverd
|
|
||||||
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
|
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# general idea
|
||||||
|
- the pi on each roomba will have a speaker and microphone
|
||||||
|
- alsa devices
|
||||||
|
- they are both the default audio device
|
||||||
|
- you will have to modify roverd, the pi install script, and the server to get this all to work
|
||||||
|
|
||||||
|
# adding stuff to installation script
|
||||||
|
- I am using the google voice AIY v1 kits for audio
|
||||||
|
- boot config stuff
|
||||||
|
- enable `dtoverlay=googlevoicehat-soundcard`
|
||||||
|
- disable `dtparam=audio=on`
|
||||||
|
- copy asound.conf in pi folder to /etc/asound.conf
|
||||||
|
|
||||||
|
# microphone
|
||||||
|
- add microphone to the SRT publish stream
|
||||||
|
|
||||||
|
# speaker
|
||||||
|
- user's chat messages will be TTS'ed through the speaker on the pi
|
||||||
|
- either flite with a way to choose the voice
|
||||||
|
- or espeak where you can choose the pitch
|
||||||
|
- on the web UI and in the chat API
|
||||||
|
- add new stuff to chat
|
||||||
|
- only shows up if you are on a rover
|
||||||
|
- only shows up if TTS is enabled on that rover
|
||||||
|
- people can choose between flite or espeak
|
||||||
|
- if they choose flite, they can choose the voice from the default flite voices (exclude awb and awb_time)
|
||||||
|
- if they choose espeak, they can choose the pitch. Have a dropdown with increments of 10 from 0 to 99
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# general idea
|
||||||
|
- uses the rover's reported battery full, warn, and urgent values
|
||||||
|
- will apply to all rovers individually
|
||||||
|
- uses the rover locking system
|
||||||
|
- completely server side
|
||||||
|
- always use the battery warn value as 0% battery and the full value as 100%
|
||||||
|
|
||||||
|
## first: the server-side server-wide event bus
|
||||||
|
- global server event bus
|
||||||
|
- used for realtime alerts between modules
|
||||||
|
- includes a way to tell where the event is coming from
|
||||||
|
|
||||||
|
## what will the battery manager do?
|
||||||
|
- watch each rover's battery charge number (reported in sensors)
|
||||||
|
- if the number reaches warn, fire an event on the event bus
|
||||||
|
- the UI will show a warning to users, independently based on sensor data
|
||||||
|
- once the rover is docked and charging, lock it.
|
||||||
|
- when the battery is fully charged, unlock it.
|
||||||
|
|
||||||
|
## while we're at it...
|
||||||
|
- add a lock reason to the rover locking system
|
||||||
|
- add the following to the rover roster in the UI session state:
|
||||||
|
- battery full #
|
||||||
|
- battery warn #
|
||||||
|
- battery urgent #
|
||||||
|
- in the rover roster UI component, add a display for battery percentage and lock reason.
|
||||||
|
- make the rover's background red when locked
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# general idea
|
||||||
|
- IR led on the front of each roomba
|
||||||
|
- connected to a pin on the pi
|
||||||
|
- made to be very directional
|
||||||
|
- each roomba has a different IR code
|
||||||
|
- use the omni reciever to get a shot
|
||||||
|
- keep score per user
|
||||||
|
- use the go alert system
|
||||||
|
- alert discord with a live score count
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# general idea
|
||||||
|
- discord bot run by the server
|
||||||
|
- use discord.js
|
||||||
|
- don't use discord's slash commands
|
||||||
|
- listen for commands the old fashioned way
|
||||||
|
- only admins with IDs set in the server config can use commands
|
||||||
|
- use nice looking embeds with colors for everything but messages
|
||||||
|
|
||||||
|
# features
|
||||||
|
|
||||||
|
## admin server management commands
|
||||||
|
- admins can lock and unlock rovers from discord
|
||||||
|
- admins can change the server access control mode
|
||||||
|
|
||||||
|
## announcements
|
||||||
|
- the bot has an announcements channel assigned in the server config. It will also have an announcement role ID to optionally ping. it will announce:
|
||||||
|
- when a rover is locked / unlocked (no ping)
|
||||||
|
- when the server access mode is changed (ping)
|
||||||
|
- the bot will have an admin ping role and alert channel. in here will be:
|
||||||
|
- rover activity (no pings)
|
||||||
|
- docking
|
||||||
|
- charging
|
||||||
|
- stopping charging
|
||||||
|
- undocking
|
||||||
|
- rover alerts (ping)
|
||||||
|
- rover comes online
|
||||||
|
- rover goes offline
|
||||||
|
- rover is at warn battery
|
||||||
|
- rover is at urgent battery
|
||||||
|
|
||||||
|
## chat bridge
|
||||||
|
- bridge between the server chat and discord chat
|
||||||
|
- use chat events that are on the bus already
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# general idea:
|
||||||
|
- control on / off switches and lights in home assistant from the web UI
|
||||||
|
- use this:
|
||||||
|
- https://www.npmjs.com/package/home-assistant-js-websocket/v/3.1.2
|
||||||
|
- remember to ignore updates that are of the same states, this library will give you a lot of those
|
||||||
|
- switches and lights are configured in the server's config file
|
||||||
|
- give each one a name (no description)
|
||||||
|
- auto detect a switch type or light type
|
||||||
|
- deliver list of lights to the UI through the session service
|
||||||
|
- show realtime on/off status of the switches / lights in the UI
|
||||||
|
- create a react component for the controls
|
||||||
|
- it will automatically create a control for each switch / light
|
||||||
|
|
||||||
|
## permissions:
|
||||||
|
- even if someone isnt assigned to a rover, they should be able to control the switches / lights
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# general idea
|
||||||
|
- The idea of this feature is to toggle night vision on the camera by turning on an LED from GPIO on the pi which will me mounted in front of the camera's light sensor
|
||||||
|
- with this, the camera will disable night vision when the LED is ON, and enable night vision when the LED is OFF.
|
||||||
|
- this will only really involve pi and webui programming. the server passes commands straight through.
|
||||||
|
- do not track the state of night vision, it is not needed.
|
||||||
|
|
||||||
|
## pi side
|
||||||
|
- new GPIO on/off control on GPIO 17, 27, or 22. any of these will work.
|
||||||
|
|
||||||
|
## web UI
|
||||||
|
- a new keyboard shortcut to toggle night vision
|
||||||
|
- a new button in the mobile UI to toggle night vision
|
||||||
|
- put it above the mobile horizontal servo slider
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
## general idea
|
||||||
|
- new page
|
||||||
|
- designed for a very small screen
|
||||||
|
- not interactive at all
|
||||||
|
- a small summarized status of what's going on in the basement (where the rovers are)
|
||||||
|
- this small screen does not have space to show all info and all video for all rovers
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# general idea
|
||||||
|
- a help system that pops up onscreen
|
||||||
|
- changes based on control layout
|
||||||
|
- mobile / desktop
|
||||||
|
- on desktop especially, keybinds will dynamically update, based on what is actually assigned
|
||||||
|
|
||||||
|
# UI layout specifics:
|
||||||
|
- remake the current help component from scratch
|
||||||
|
- leave it in the place where it is, so people can always look at the help
|
||||||
|
- match the styling of the rest of the page
|
||||||
|
- make sure the help is easy to edit
|
||||||
|
- add a pop-up which displays underneath any other popup layers, which will display the help component in a large format
|
||||||
|
- make this pop-up show every time UNLESS the user checks "dont show again"
|
||||||
|
- use the settings system to store this in the browser
|
||||||
|
|
||||||
|
# what will the help explain?
|
||||||
|
- how to use the nickname and chat
|
||||||
|
- how to start driving the rover
|
||||||
|
- rover controls
|
||||||
|
- this is the biggest thing that will change in different layouts
|
||||||
|
- how it works technically (just a placeholder for now)
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
webrtcMaxPlayoutDelay: 0
|
webrtcMaxPlayoutDelay: 0
|
||||||
alwaysRemuxWhep: no
|
alwaysRemuxWhep: no
|
||||||
```
|
```
|
||||||
- Pis publish to `srt://<server>:9000` with the streamid above; mediaMTX auto-creates the path and fans it out over WHEP/WebRTC.
|
- Pis publish to `srt://<server>:9000` with the streamid above; mediaMTX auto-creates the path and fans it out over WHEP/WebRTC. If you expose playback under `/video/<id>` externally, let the reverse proxy rewrite that prefix back to `<id>` before forwarding to mediaMTX so the wildcard continues to match every rover.
|
||||||
- Only playback is gated: mediaMTX calls the Node server to validate JWTs on `/whep/rover-<id>`, so “locking” a stream is as simple as refusing to mint viewer tokens for that rover. Ingest stays unauthenticated because it lives on a secure LAN.
|
- Only playback is gated: mediaMTX calls the Node server to validate JWTs on `/whep/rover-<id>`, so “locking” a stream is as simple as refusing to mint viewer tokens for that rover. Ingest stays unauthenticated because it lives on a secure LAN.
|
||||||
|
|
||||||
3. **Web clients**
|
3. **Web clients**
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# nicknames:
|
||||||
|
- users will be able to have and set nicknames
|
||||||
|
- a user's nickname will store in the browser using persistence.js
|
||||||
|
- the user's nickname should probably just be in socket.data.nickname
|
||||||
|
- on connection, the web UI will tell the server "this is my nickname"
|
||||||
|
- completely enforced by the web UI
|
||||||
|
- replace any place in the web UI that shows a socket ID with the nickname
|
||||||
|
- create a small react component which can set your nickname and save it
|
||||||
|
|
||||||
|
# user list in session data:
|
||||||
|
- add a list of users to the session data for the web UI
|
||||||
|
- contains for each user:
|
||||||
|
- socket ID
|
||||||
|
- nickname
|
||||||
|
- the rover that they are driving
|
||||||
|
- their role (user, admin, lockdown, spectator)
|
||||||
|
|
||||||
|
# user list:
|
||||||
|
- a list of users
|
||||||
|
- new react component
|
||||||
|
- uses user list with nicknames from session data
|
||||||
|
- show their nickname and whether or not they are an admin
|
||||||
|
|
||||||
|
# chat:
|
||||||
|
- server side chat system:
|
||||||
|
- don't store message history
|
||||||
|
- emit an event to the event bus for each message
|
||||||
|
- eventually will be forwarded to discord through a bot
|
||||||
|
- listen to chat message events on the bus
|
||||||
|
- eventually the discord bot will also send messages to the server chat
|
||||||
|
- profanity filter
|
||||||
|
- spam filter
|
||||||
|
- repeated words
|
||||||
|
- keymashing
|
||||||
|
- etc
|
||||||
|
- somehow link chat messages to rovers
|
||||||
|
- in the future rovers will have TTS onboard, and will speak chat messages only from the person driving the rover
|
||||||
|
- chat on the UI
|
||||||
|
- new react component
|
||||||
|
- show messages as they come in:
|
||||||
|
- time (just like 19:23), sender nickname, rover they are driving, message
|
||||||
|
- pressing enter on the keyboard will pause rover control, and focus the chat box.
|
||||||
|
- pressing enter again after typing will send the message
|
||||||
|
|
||||||
|
|
||||||
|
# layout and styling for new UI elements:
|
||||||
|
- use index.css styles to match the new stuff to the current UI
|
||||||
|
- just edit the desktop page for now
|
||||||
|
- layout:
|
||||||
|
- in the left column, a new row in between video and logs
|
||||||
|
- 50/50 split between:
|
||||||
|
- user list and nickname entry
|
||||||
|
- chat history and chat box
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# general idea
|
||||||
|
- the pi's microphone needs to be streamed to users
|
||||||
|
- as part of the rover's video feed
|
||||||
|
- the pi model is a zero 2 W
|
||||||
|
- it CANNOT handle transcoding the audio onboard, I have tried.
|
||||||
|
- the server is super powerful and can handle transcoding
|
||||||
|
- it is NOT acceptable to add more than 100ms of video latency from glass to glass
|
||||||
|
- it is NOT acceptable to break video streaming for pis that don't have microphones
|
||||||
|
|
||||||
|
### OLD, BAD, but working audio configuration that worked for the microphone
|
||||||
|
```
|
||||||
|
-f alsa -guess_layout_max 0 -thread_queue_size 2048 -ac 1 -ar 48000 -i plughw:0,0 \
|
||||||
|
-map 1:a:0 \
|
||||||
|
-c:a libopus -b:a 24000 -compression_level 0 -application voip -frame_duration 60 \
|
||||||
|
-ac:a 1 -ar:a 48000 -af "pan=1c|c0=c0,volume=20dB"
|
||||||
|
```
|
||||||
|
- the raspberry pi CANNOT HANDLE AUDIO ENCODING onboard. this is just to show you how to use the mic.
|
||||||
|
|
||||||
|
|
||||||
|
sudo systemctl restart roverd
|
||||||
|
#sudo systemctl restart audio-capture
|
||||||
|
sudo systemctl restart video-publisher
|
||||||
|
sudo cat /var/lib/roverd/video.env
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## raspberry pi
|
||||||
|
<!-- - need to throttle sensor sending, maybe only send one in every 5 packets. -->
|
||||||
|
|
||||||
|
## 3d models for camera:
|
||||||
|
- https://www.thingiverse.com/thing:2873677
|
||||||
|
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
|
||||||
|
- https://www.thingiverse.com/thing:4514531
|
||||||
|
|
||||||
|
## todo
|
||||||
|
<!-- 1. battery manager -->
|
||||||
|
<!-- 2. pi-side sensor throttle (1/5th) -->
|
||||||
|
<!-- 3. convert rover roster to a reusable component, unbake it from telemetry and admin panels -->
|
||||||
|
<!-- 4. room cameras -->
|
||||||
|
4. fix gamepad input - more complex axis assignment? division, combined axes?
|
||||||
|
<!-- 5. home assistant controls -->
|
||||||
|
<!-- 6. nicknames -->
|
||||||
|
<!-- 7. discord bot -->
|
||||||
|
<!-- 8. turns mode display -->
|
||||||
|
<!-- 9. online user list -->
|
||||||
|
<!-- 10. chat -->
|
||||||
|
<!-- 9. discord invite button -->
|
||||||
|
<!-- 10. redo both mobile layouts -->
|
||||||
|
<!-- 11. redo spectator view (last) -->
|
||||||
|
<!-- 21. finally.. set the favicon and title -->
|
||||||
|
22. rover snapshots freezing and never coming back
|
||||||
|
23. rover snapshot -> video switching needs to be smoother, no black flash. connect and play before showing.
|
||||||
|
24. rover snapshots delayed (not just because of framerate)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# general idea
|
||||||
|
- a modular control system
|
||||||
|
- every piece of the UI that uses rover controls will go through this, including onscreen click buttons
|
||||||
|
- allows for dynamic control labels based on saved settings
|
||||||
|
- allow a place to assign OI command macros
|
||||||
|
- like the one that the drive button uses now
|
||||||
|
- allows realtime responsive control of the rover that you are driving
|
||||||
|
|
||||||
|
## some sort of system for the site to save settings per browser
|
||||||
|
- needs to be extensible
|
||||||
|
- future things will use it
|
||||||
|
- expose functions like saveSettings and loadSettings
|
||||||
|
- other parts of the UI will need to save and load settings using this function
|
||||||
|
- use cookies
|
||||||
|
|
||||||
|
## keyboard controls:
|
||||||
|
- remappable by new component in settings tab
|
||||||
|
- key mappings will save
|
||||||
|
- controls for the keyboard
|
||||||
|
- driving
|
||||||
|
- WASD blended for tank steering
|
||||||
|
- hold backslash to move faster
|
||||||
|
- hold right shift to move slower
|
||||||
|
- aux motors
|
||||||
|
- main brush
|
||||||
|
- hold O to move it forward at speed 127
|
||||||
|
- hold L to move it backward at speed -127
|
||||||
|
- side brush
|
||||||
|
- hold P to move it forward at speed 127
|
||||||
|
- hold ; to move it backward at speed -70
|
||||||
|
- vacuum motor
|
||||||
|
- hold [ to move it at speed 127
|
||||||
|
- hold ' to move it at speed 50
|
||||||
|
- ALL AUX MOTORS
|
||||||
|
- hold . to move at full speed forward (127)
|
||||||
|
- camera movement
|
||||||
|
- hold I to look up
|
||||||
|
- hold K to look down
|
||||||
|
- dock and drive hotkeys
|
||||||
|
- G for drive (use same 3 part macro as the drive button)
|
||||||
|
- H for dock (seek dock command)
|
||||||
|
|
||||||
|
## Mobile controls
|
||||||
|
- 2 different layouts, already implemented just needs improved.
|
||||||
|
### mobile landscape
|
||||||
|
- a video game style layout
|
||||||
|
- on the left, buttons that you hold to operate the aux motors
|
||||||
|
- in the middle, is the rover video component.
|
||||||
|
- on the right, an area for a floating joystick and above is a unified control to see and change the rover's mode (drive or dock)
|
||||||
|
### mobile portrait
|
||||||
|
- designed to be driven vertically with 2 hands
|
||||||
|
- mostly fine already
|
||||||
|
- rover video at the top
|
||||||
|
- then below it, is a section with the aux motor buttons on the left, and the floating joystick are on the right.
|
||||||
|
|
||||||
|
|
||||||
|
## Gamepad controls:
|
||||||
|
- use some sort of react thing that makes it easy to use the web gamepad stuff
|
||||||
|
- left joystick for movement, right joystick for moving camera up / down
|
||||||
|
- both fully analog
|
||||||
|
- right trigger for the main brush motor
|
||||||
|
- fully analog
|
||||||
|
- press right bumper to switch it to reverse
|
||||||
|
- left trigger for the side brush motor
|
||||||
|
- fully analog
|
||||||
|
- press left bumper to switch it to reverse
|
||||||
|
- hold the right face button to run the vacuum motor
|
||||||
|
- hold the lower face button to run all aux motors forward
|
||||||
|
- left and right on the Dpad switch between drive and dock modes
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# general idea
|
||||||
|
- convert room cameras from webrtc h264 streams to 4fps jpegs sent over socket.io
|
||||||
|
|
||||||
|
## requirements
|
||||||
|
- make updated services for server and pi room cams
|
||||||
|
- no backwards compatability is needed
|
||||||
|
- room cameras are both 4:3, make sure they show as such in the UI
|
||||||
|
- remove anything related to the webRTC room cams
|
||||||
|
- send the jpegs efficiently (as binary)
|
||||||
|
- make sure the room cams are still authed as they are now
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# general idea
|
||||||
|
- add support for multiple room cameras through the server to the UI
|
||||||
|
|
||||||
|
## what needs to happen on the server:
|
||||||
|
- add support for a path of room cameras on mediamtx
|
||||||
|
- /room/<camera_name>
|
||||||
|
- CANNOT interfere with rover cameras (/<rover_name>)
|
||||||
|
- needs to use the same auth system as the rover camera
|
||||||
|
- room cameras will be streamed to the server over SRT
|
||||||
|
|
||||||
|
## what needs to happen in the web UI
|
||||||
|
- users should be able to see all room cameras, even if not assigned to a rover
|
||||||
|
- automatically add a room camera player for each room camera
|
||||||
|
- make a component that shows all room cameras
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# spectate system overhaul
|
||||||
|
- rip out and remake the entire page
|
||||||
|
- basically keep nothing from it
|
||||||
|
- right now the spectator page is a mess, and hasn't been updated in a while
|
||||||
|
- does not work properly, aside from the page being out of date
|
||||||
|
- the spectate page should use modules from the driver page
|
||||||
|
- there should be no such thing as a "spectate mode" on the driver page. If someone wants to spectate, they must go to the spectator page.
|
||||||
|
|
||||||
|
## rules of the spectator page
|
||||||
|
- spectators are not allowed to drive
|
||||||
|
- spectators are not allowed to chat
|
||||||
|
- spectators are not allowed to have nicknames
|
||||||
|
- spectators are not allowed to login as admin
|
||||||
|
- these are the permissions for spectators based on the sever's access control mode
|
||||||
|
- open
|
||||||
|
- spectators can spectate
|
||||||
|
- turns
|
||||||
|
- spectators can spectate
|
||||||
|
- admin
|
||||||
|
- spectators can spectate
|
||||||
|
- lockdown
|
||||||
|
- spectators can NOT spectate
|
||||||
|
|
||||||
|
|
||||||
|
## what should be on the spectator page?
|
||||||
|
- for each rover
|
||||||
|
- video component
|
||||||
|
- current driver
|
||||||
|
- sensor telemetry component
|
||||||
|
- logs
|
||||||
|
- room cameras
|
||||||
|
- online user list
|
||||||
|
|
||||||
|
## layout and styling of the spectator page
|
||||||
|
- use same styling style as the driver page
|
||||||
|
- feel free to add spectator role checks to hide the features that spectators can't use in certain components.
|
||||||
|
- mainly designed for a 4:3 monitor
|
||||||
|
- make use of the vertical space
|
||||||
|
- a row of columns, one for each rover at the top
|
||||||
|
- below the row of columns:
|
||||||
|
- 50/50 split
|
||||||
|
- user list
|
||||||
|
- chat
|
||||||
|
- room cameras
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# general idea
|
||||||
|
- users can choose from preset, and create UI themes
|
||||||
|
- the default theme is the current in place theme
|
||||||
|
- themes will NOT change margins, borders, padding, spacing, etc.
|
||||||
|
- should be as simple as changing the global css stuff
|
||||||
|
- themes can change colors, fonts, border radius, borders, etc
|
||||||
|
|
||||||
|
## technical stuff
|
||||||
|
- save theme info in
|
||||||
|
|
||||||
|
## UI for themeing
|
||||||
|
- allow people to tune every theme element that there is to tune
|
||||||
|
- allow people to export themes as json files, and import them
|
||||||
|
-
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# general idea
|
||||||
|
- a bunch of small UI adjustments
|
||||||
|
- always keep styling consistent to the rest of the page
|
||||||
|
|
||||||
|
## everywhere
|
||||||
|
- add an audio stream status to the rover video panel, only show it if the rover has the separate audio stream
|
||||||
|
- make the room camera panels save their stack setting per instance of the panel in the web UI's settings storage system
|
||||||
|
- remove the status bar below room cameras
|
||||||
|
- put it in the corner of the feed, like the other video panels
|
||||||
|
- make the styling of the room camera panels match everything else
|
||||||
|
- when the user list switches to turns, always show the plain user list, but below the turns info
|
||||||
|
|
||||||
|
## on the spectator page
|
||||||
|
- rover video panels
|
||||||
|
- no more telemetry bar below each rover video
|
||||||
|
- ovelay the tiny telemetry summary on the left side of the video, with the rest of the HUD elements
|
||||||
|
- including the battery bar, make it vertical on the right side of the video
|
||||||
|
- no more title bar above each rover video
|
||||||
|
- the rover's name is already on the HUD
|
||||||
|
- add current driver to the HUD
|
||||||
|
- sidebar
|
||||||
|
- shrink the logs panel
|
||||||
|
- add an actual rover list to the top of the sidebar
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
## video stream
|
|
||||||
- video stream needs to always autoplay, even if it cant unmute itself
|
|
||||||
- currently, it wont play at all unless it can unmute
|
|
||||||
- video stream currently unloads and reloads based on the tab being active
|
|
||||||
- just remove this feature. It needs to stay running all the time.
|
|
||||||
|
|
||||||
## server sensor decoding
|
|
||||||
- OI mode is not decoded properly
|
|
||||||
- says "safe" when in passive, and "unknown" when in full.
|
|
||||||
|
|
||||||
## UI and session info
|
|
||||||
- UI needs a display for the turns mode
|
|
||||||
|
|
||||||
## Mobile UI
|
|
||||||
- need to make it good
|
|
||||||
- joystick needs to be floating
|
|
||||||
- aux motor buttons need redone
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Room camera snapshot service
|
||||||
|
|
||||||
|
Lightweight systemd service that serves a JPEG snapshot from any MJPEG-capable webcam (most USB webcams) at 4 fps. The server pulls `http://<host>:8080/snapshot.jpg` for room cams.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `room-cam-snapshot.sh` – ffmpeg + simple HTTP server wrapper
|
||||||
|
- `room-cam.service` – systemd unit template
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
1) Copy the service into place (adjust path/env as needed):
|
||||||
|
```bash
|
||||||
|
sudo cp roomcam-service/room-cam.service /etc/systemd/system/room-cam.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now room-cam.service
|
||||||
|
```
|
||||||
|
2) Override defaults via `Environment=` in the unit or drop-ins:
|
||||||
|
- `DEVICE=/dev/video0`
|
||||||
|
- `RESOLUTION=640x480`
|
||||||
|
- `QUALITY=5` (ffmpeg MJPEG quality; lower is higher quality)
|
||||||
|
- `PORT=8080`
|
||||||
|
- `WORKDIR=/run/roomcam`
|
||||||
|
- `INPUT_FORMAT=mjpeg` (use `bayer_grbg8` for OV534/raw Bayer cams; aliases `GRBG`/`grbg` are accepted)
|
||||||
|
3) Point the server `roomCameras[].url` to `http://<host>:8080/snapshot.jpg`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The camera runs at its native MJPEG frame rate; the server polls snapshots at ~4 fps, so no extra filtering is applied here.
|
||||||
|
|
||||||
|
To run outside systemd, just execute `./room-cam-snapshot.sh` with any overrides.
|
||||||
@@ -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
|
||||||
@@ -7,7 +7,51 @@ admins:
|
|||||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||||
discord_id: "0987654321"
|
discord_id: "0987654321"
|
||||||
lockdown: true
|
lockdown: true
|
||||||
|
timezone: "America/New_York"
|
||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port). The UI will always request
|
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
whepBaseUrl: "http://192.168.0.86:8889"
|
# Example: http://192.168.0.86:8889/video
|
||||||
|
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||||
|
preview:
|
||||||
|
enabled: false
|
||||||
|
codec: "av1"
|
||||||
|
transport: "rtsp"
|
||||||
|
fps: 10
|
||||||
|
width: 640
|
||||||
|
roomBitrateKbps: 200
|
||||||
|
roverBitrateKbps: 350
|
||||||
|
gopSeconds: 2
|
||||||
|
|
||||||
|
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
|
||||||
|
channels:
|
||||||
|
announcements: "123456789012345678"
|
||||||
|
adminAlerts: "123456789012345678"
|
||||||
|
# chat bridge is configured per guild via `rs bridge` commands
|
||||||
|
replay: "123456789012345678"
|
||||||
|
roles:
|
||||||
|
announcementPing: "123456789012345678"
|
||||||
|
adminPing: "123456789012345678"
|
||||||
|
|||||||
@@ -8,15 +8,28 @@ require('./src/helpers/sensorDecoder');
|
|||||||
|
|
||||||
require('./src/services/alertService');
|
require('./src/services/alertService');
|
||||||
require('./src/services/authService');
|
require('./src/services/authService');
|
||||||
|
require('./src/services/eventBus');
|
||||||
require('./src/services/modeManager');
|
require('./src/services/modeManager');
|
||||||
require('./src/services/lockdownGuard');
|
require('./src/services/lockdownGuard');
|
||||||
require('./src/services/roverManager');
|
require('./src/services/roverManager');
|
||||||
require('./src/services/commandService');
|
require('./src/services/commandService');
|
||||||
require('./src/services/roverConnectionService');
|
require('./src/services/roverConnectionService');
|
||||||
require('./src/services/assignmentService');
|
require('./src/services/assignmentService');
|
||||||
|
require('./src/services/nicknameService');
|
||||||
|
require('./src/services/chatService');
|
||||||
|
require('./src/services/communityGoalService');
|
||||||
require('./src/services/videoSessions');
|
require('./src/services/videoSessions');
|
||||||
require('./src/services/videoAuthService');
|
require('./src/services/videoAuthService');
|
||||||
require('./src/services/videoSocketService');
|
require('./src/services/videoSocketService');
|
||||||
|
require('./src/services/roomCameraSocketService');
|
||||||
|
require('./src/services/roverSnapshotSocketService');
|
||||||
|
require('./src/services/embedHttpService');
|
||||||
require('./src/services/logStreamService');
|
require('./src/services/logStreamService');
|
||||||
|
require('./src/services/homeAssistantService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
|
require('./src/services/batteryManager');
|
||||||
|
require('./src/services/replaySocketService');
|
||||||
|
require('./src/services/replaySegmentManager');
|
||||||
|
require('./src/services/media/previewTranscoderService');
|
||||||
|
require('./src/services/discordBotService');
|
||||||
require('./src/services/httpServer');
|
require('./src/services/httpServer');
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
|||||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.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
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This installer must be run with sudo/root." >&2
|
echo "This installer must be run with sudo/root." >&2
|
||||||
@@ -74,6 +76,10 @@ install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
|||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||||
|
|
||||||
echo "[4/6] Writing systemd units..."
|
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
|
cat > "$MEDIAMTX_SERVICE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=mediaMTX WebRTC Server
|
Description=mediaMTX WebRTC Server
|
||||||
@@ -84,6 +90,7 @@ Wants=network-online.target
|
|||||||
User=$TARGET_USER
|
User=$TARGET_USER
|
||||||
Group=$TARGET_USER
|
Group=$TARGET_USER
|
||||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
||||||
|
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
@@ -104,6 +111,8 @@ Group=$TARGET_USER
|
|||||||
WorkingDirectory=$SERVER_DIR
|
WorkingDirectory=$SERVER_DIR
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
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
|
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|||||||
@@ -8,15 +8,24 @@ metricsAddress: 0.0.0.0:9998
|
|||||||
pprof: no
|
pprof: no
|
||||||
pprofAddress: 127.0.0.1:9999
|
pprofAddress: 127.0.0.1:9999
|
||||||
|
|
||||||
rtsp: no
|
rtsp: yes
|
||||||
rtmp: no
|
rtmp: no
|
||||||
hls: no
|
hls: no
|
||||||
|
|
||||||
webrtc: yes
|
webrtc: yes
|
||||||
webrtcLocalUDPAddress: :8189
|
webrtcLocalUDPAddress: :8189
|
||||||
webrtcLocalTCPAddress: :8189
|
webrtcLocalTCPAddress: :8189
|
||||||
webrtcAdditionalHosts: []
|
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
|
||||||
webrtcICEServers2: []
|
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
|
srt: yes
|
||||||
srtAddress: :9000
|
srtAddress: :9000
|
||||||
@@ -33,5 +42,3 @@ paths:
|
|||||||
all:
|
all:
|
||||||
source: publisher
|
source: publisher
|
||||||
sourceOnDemand: no
|
sourceOnDemand: no
|
||||||
# webrtcMaxPlayoutDelay: 0
|
|
||||||
# alwaysRemuxWhep: no
|
|
||||||
|
|||||||
Generated
+271
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"discord.js": "^14.25.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"home-assistant-js-websocket": "^3.1.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
@@ -20,6 +22,169 @@
|
|||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@discordjs/builders": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/formatters": "^0.6.1",
|
||||||
|
"@discordjs/util": "^1.1.1",
|
||||||
|
"@sapphire/shapeshift": "^4.0.0",
|
||||||
|
"discord-api-types": "^0.38.31",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"ts-mixer": "^6.0.4",
|
||||||
|
"tslib": "^2.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/collection": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/formatters": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"discord-api-types": "^0.38.33"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/rest": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/collection": "^2.1.1",
|
||||||
|
"@discordjs/util": "^1.1.1",
|
||||||
|
"@sapphire/async-queue": "^1.5.3",
|
||||||
|
"@sapphire/snowflake": "^3.5.3",
|
||||||
|
"@vladfrangu/async_event_emitter": "^2.4.6",
|
||||||
|
"discord-api-types": "^0.38.16",
|
||||||
|
"magic-bytes.js": "^1.10.0",
|
||||||
|
"tslib": "^2.6.3",
|
||||||
|
"undici": "6.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/util": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"discord-api-types": "^0.38.33"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/ws": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/collection": "^2.1.0",
|
||||||
|
"@discordjs/rest": "^2.5.1",
|
||||||
|
"@discordjs/util": "^1.1.0",
|
||||||
|
"@sapphire/async-queue": "^1.5.2",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"@vladfrangu/async_event_emitter": "^2.2.4",
|
||||||
|
"discord-api-types": "^0.38.1",
|
||||||
|
"tslib": "^2.6.2",
|
||||||
|
"ws": "^8.17.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sapphire/async-queue": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v14.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sapphire/shapeshift": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sapphire/snowflake": {
|
||||||
|
"version": "3.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
||||||
|
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v14.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
@@ -44,6 +209,25 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vladfrangu/async_event_emitter": {
|
||||||
|
"version": "2.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
|
||||||
|
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v14.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -339,6 +523,42 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/discord-api-types": {
|
||||||
|
"version": "0.38.34",
|
||||||
|
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.34.tgz",
|
||||||
|
"integrity": "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"scripts/actions/documentation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/discord.js": {
|
||||||
|
"version": "14.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
|
||||||
|
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/builders": "^1.13.0",
|
||||||
|
"@discordjs/collection": "1.5.3",
|
||||||
|
"@discordjs/formatters": "^0.6.2",
|
||||||
|
"@discordjs/rest": "^2.6.0",
|
||||||
|
"@discordjs/util": "^1.2.0",
|
||||||
|
"@discordjs/ws": "^1.2.3",
|
||||||
|
"@sapphire/snowflake": "3.5.3",
|
||||||
|
"discord-api-types": "^0.38.33",
|
||||||
|
"fast-deep-equal": "3.1.3",
|
||||||
|
"lodash.snakecase": "4.1.1",
|
||||||
|
"magic-bytes.js": "^1.10.0",
|
||||||
|
"tslib": "^2.6.3",
|
||||||
|
"undici": "6.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -541,6 +761,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -695,6 +921,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/home-assistant-js-websocket": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-HzaJtuhufBkppfXerla5cJHSP8uXzLoSRV+XPCcYlOttiKzqLQHD3sPm2T9UHTHPPppy3jRZtaA86ISfH3GK/g==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -803,6 +1035,24 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.snakecase": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/magic-bytes.js": {
|
||||||
|
"version": "1.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz",
|
||||||
|
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1523,6 +1773,18 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-mixer": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -1543,6 +1805,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "6.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||||
|
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
|||||||
@@ -9,9 +9,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"discord.js": "^14.25.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"home-assistant-js-websocket": "^3.1.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -2,11 +2,17 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/bitmap.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>webui</title>
|
<meta name="theme-color" content="#020617" />
|
||||||
<script type="module" crossorigin src="/assets/index-BvgUoEqO.js"></script>
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DwQ38q3M.css">
|
<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-B7raLs13.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Multi Roomba Rover",
|
||||||
|
"short_name": "MRR",
|
||||||
|
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#020617",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/bitmap.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
const path = require('path');
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const morgan = require('morgan');
|
const morgan = require('morgan');
|
||||||
@@ -7,10 +6,7 @@ const config = require('./config');
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(morgan('dev'));
|
app.use(morgan('dev'));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(config.staticDir));
|
app.use(express.static(config.staticDir, { index: false }));
|
||||||
app.get('/spectate', (req, res) => {
|
|
||||||
res.sendFile(path.join(config.staticDir, 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const httpServer = http.createServer(app);
|
const httpServer = http.createServer(app);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ const { httpServer } = require('./http');
|
|||||||
|
|
||||||
const io = new SocketIOServer(httpServer, {
|
const io = new SocketIOServer(httpServer, {
|
||||||
cors: { origin: '*' },
|
cors: { origin: '*' },
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
pingInterval: 5000,
|
||||||
|
pingTimeout: 7000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allow more service listeners without warnings.
|
||||||
|
io.sockets.setMaxListeners(30);
|
||||||
|
io.of('/').setMaxListeners(30);
|
||||||
|
|
||||||
module.exports = io;
|
module.exports = io;
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|||||||
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||||
roverWSS.emit('connection', ws, req);
|
roverWSS.emit('connection', ws, req);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const CHARGING_STATE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OI_MODES = {
|
const OI_MODES = {
|
||||||
0: 'off',
|
1: 'off',
|
||||||
1: 'passive',
|
2: 'passive',
|
||||||
2: 'safe',
|
3: 'safe',
|
||||||
3: 'full',
|
4: 'full',
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUP100_LAYOUT = [
|
const GROUP100_LAYOUT = [
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
info: '#2196f3',
|
|
||||||
success: '#4caf50',
|
|
||||||
warn: '#f0b651',
|
|
||||||
error: '#e53935',
|
|
||||||
};
|
|
||||||
|
|
||||||
function sendAlert({ color, title, message, ts = Date.now() }) {
|
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||||
const payload = {
|
const payload = {
|
||||||
color: color || COLORS.info,
|
color: color || '#2196f3',
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
ts,
|
ts,
|
||||||
@@ -19,6 +12,5 @@ function sendAlert({ color, title, message, ts = Date.now() }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
COLORS,
|
|
||||||
sendAlert,
|
sendAlert,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ roverManager.managerEvents.on('rover', ({ action }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
function assignSocket(socket) {
|
||||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||||
return;
|
return;
|
||||||
@@ -129,6 +138,21 @@ function releaseAssignment(socket, roverId) {
|
|||||||
assignmentEvents.emit('update', socket.id);
|
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() {
|
function pickRover() {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||||
@@ -161,4 +185,16 @@ function describeAssignment(socketId) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
assignmentEvents,
|
assignmentEvents,
|
||||||
describeAssignment,
|
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,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,234 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('chatService');
|
||||||
|
const { publishEvent, subscribe } = require('./eventBus');
|
||||||
|
const { getRole } = require('./roleService');
|
||||||
|
const { describeAssignment } = require('./assignmentService');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { getNickname } = require('./nicknameService');
|
||||||
|
const { issueCommand } = require('./commandService');
|
||||||
|
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||||
|
const RATE_LIMIT_MAX = 5;
|
||||||
|
const rateBuckets = new Map(); // socketId -> [timestamps]
|
||||||
|
|
||||||
|
const MAX_HISTORY = 100;
|
||||||
|
const history = [];
|
||||||
|
|
||||||
|
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick', 'faggot', 'fag', 'whore'];
|
||||||
|
const DUPLICATE_WINDOW_MS = 15000;
|
||||||
|
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
return PROFANITY_LIST.some((word) => lower.includes(word));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
return {
|
||||||
|
id: uuidv4(),
|
||||||
|
ts: Date.now(),
|
||||||
|
socketId: socket?.id || null,
|
||||||
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
|
role: meta.role || getRole(socket),
|
||||||
|
roverId,
|
||||||
|
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,
|
||||||
|
text,
|
||||||
|
tts: meta.tts || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHistory(message) {
|
||||||
|
history.push(message);
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastMessage(message) {
|
||||||
|
pushHistory(message);
|
||||||
|
publishEvent({ source: 'chat', type: 'chat:message', payload: 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 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 > 150) {
|
||||||
|
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, tts: ttsOptions });
|
||||||
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
|
broadcastMessage(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;
|
||||||
|
// if (!roverManager.canDrive(message.roverId, socket)) 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 > 256) {
|
||||||
|
throw new Error('Message invalid');
|
||||||
|
}
|
||||||
|
if (hasProfanity(clean)) {
|
||||||
|
throw new Error('Message blocked');
|
||||||
|
}
|
||||||
|
if (isKeymash(clean)) {
|
||||||
|
throw new Error('Message looks like spam');
|
||||||
|
}
|
||||||
|
const message = buildMessage(null, clean, {
|
||||||
|
nickname,
|
||||||
|
role,
|
||||||
|
roverId,
|
||||||
|
fromDiscord: true,
|
||||||
|
discordGuildId,
|
||||||
|
discordGuildName,
|
||||||
|
discordGuildIconUrl,
|
||||||
|
discordChannelId,
|
||||||
|
discordUserId,
|
||||||
|
discordUserName,
|
||||||
|
discordUserAvatarUrl,
|
||||||
|
});
|
||||||
|
logger.info('External chat message', { roverId, nickname });
|
||||||
|
broadcastMessage(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.emit('chat:init', history);
|
||||||
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||||
|
});
|
||||||
|
|
||||||
|
subscribe('chat:message', ({ payload }) => {
|
||||||
|
if (!payload) return;
|
||||||
|
io.emit('chat:message', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleIncoming,
|
||||||
|
sendExternalMessage,
|
||||||
|
};
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
|
const { isAdmin } = require('./roleService');
|
||||||
const logger = require('../globals/logger').child('commandService');
|
const logger = require('../globals/logger').child('commandService');
|
||||||
|
|
||||||
const pendingCommands = new Map(); // id -> { roverId }
|
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) {
|
function issueCommand(roverId, payload) {
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
@@ -31,9 +34,27 @@ function handleAck(msg) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRecentDriveActivity(windowMs, options = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const results = [];
|
||||||
|
for (const [roverId, info] of lastDriveActivity.entries()) {
|
||||||
|
if (!info || now - info.ts > windowMs) continue;
|
||||||
|
if (options.excludeAdmins && info.isAdmin) continue;
|
||||||
|
results.push({ roverId, ...info });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDriveCooldown(roverId, durationMs) {
|
||||||
|
if (!roverId || !durationMs) return;
|
||||||
|
driveCooldowns.set(roverId, Date.now() + durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
issueCommand,
|
issueCommand,
|
||||||
handleAck,
|
handleAck,
|
||||||
|
getRecentDriveActivity,
|
||||||
|
setDriveCooldown,
|
||||||
};
|
};
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
@@ -47,8 +68,37 @@ io.on('connection', (socket) => {
|
|||||||
throw new Error('Not your turn or no control');
|
throw new Error('Not your turn or no control');
|
||||||
}
|
}
|
||||||
const payload = data ? { ...data } : {};
|
const payload = data ? { ...data } : {};
|
||||||
|
const isAdminSocket = isAdmin(socket);
|
||||||
|
const driveDirect = payload?.driveDirect;
|
||||||
|
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||||
|
const left = Number(driveDirect.left);
|
||||||
|
const right = Number(driveDirect.right);
|
||||||
|
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||||
|
const blockedUntil = driveCooldowns.get(roverId);
|
||||||
|
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||||
|
throw new Error('Drive blocked: dock protection cooldown');
|
||||||
|
}
|
||||||
|
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 });
|
const id = issueCommand(roverId, { type, ...payload });
|
||||||
logger.info('Queued command', socket.id, roverId, type);
|
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 });
|
reply({ id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Command rejected', socket.id, err.message);
|
logger.warn('Command rejected', socket.id, err.message);
|
||||||
|
|||||||
@@ -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,265 @@
|
|||||||
|
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 buildEmbedCopy(state, camera) {
|
||||||
|
const roversOnline = state?.rovers?.length || 0;
|
||||||
|
const driverCount = Object.keys(state?.activeDrivers || {}).length;
|
||||||
|
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: roverManager.getRoster(),
|
||||||
|
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: roverManager.getRoster(),
|
||||||
|
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,267 @@
|
|||||||
|
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 } = 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;
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
id: meta.id,
|
||||||
|
name,
|
||||||
|
type: meta.type,
|
||||||
|
state: 'unknown',
|
||||||
|
available: false,
|
||||||
|
lastChanged: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {}) => {
|
||||||
|
if ((getMode() === 'admin' || getMode() === 'lockdown') && isAdmin(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 = () => {}) => {
|
||||||
|
if ((getMode() === 'admin' || getMode() === 'lockdown') && isAdmin(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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getState,
|
||||||
|
toggleEntity,
|
||||||
|
setEntityState,
|
||||||
|
homeAssistantEvents: events,
|
||||||
|
};
|
||||||
@@ -24,13 +24,8 @@ function enforceLockdown() {
|
|||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
socket.data.lockdownTimer = setTimeout(() => {
|
|
||||||
if (!isLockdownAdmin(socket)) {
|
|
||||||
disconnectForLockdown(socket);
|
disconnectForLockdown(socket);
|
||||||
}
|
}
|
||||||
}, 10000);
|
|
||||||
socket.once('disconnect', () => clearLockdownTimer(socket));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const logger = require('../../globals/logger').child('previewTranscoder');
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
const config = loadConfig();
|
||||||
|
const mediaConfig = config.media || {};
|
||||||
|
const previewConfig = mediaConfig.preview || {};
|
||||||
|
|
||||||
|
const ENABLED = Boolean(previewConfig.enabled);
|
||||||
|
const PREVIEW_CODEC = String(previewConfig.codec || 'av1').toLowerCase();
|
||||||
|
const PREVIEW_TRANSPORT = String(previewConfig.transport || 'rtsp').toLowerCase();
|
||||||
|
const PREVIEW_FPS = Number(previewConfig.fps || 10);
|
||||||
|
const PREVIEW_WIDTH = Number(previewConfig.width || 640);
|
||||||
|
const ROOM_BITRATE_KBPS = Number(previewConfig.roomBitrateKbps || 200);
|
||||||
|
const ROVER_BITRATE_KBPS = Number(previewConfig.roverBitrateKbps || 350);
|
||||||
|
const PRESET = Number.isFinite(previewConfig.preset) ? String(previewConfig.preset) : '8';
|
||||||
|
const GOP_SECONDS = Number(previewConfig.gopSeconds || 2);
|
||||||
|
const FFMPEG_BIN = previewConfig.ffmpegBin || process.env.FFMPEG_BIN || 'ffmpeg';
|
||||||
|
|
||||||
|
const recorders = new Map(); // key -> { proc, source }
|
||||||
|
let syncTimer = null;
|
||||||
|
|
||||||
|
function encodeStreamId(streamId) {
|
||||||
|
return encodeURIComponent(streamId).replace(/%2F/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSrtReadUrl(streamId) {
|
||||||
|
return `srt://127.0.0.1:9000?streamid=read:${encodeStreamId(streamId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSrtPublishUrl(streamId) {
|
||||||
|
const encoded = encodeStreamId(streamId);
|
||||||
|
return `srt://127.0.0.1:9000?streamid=#!::r=${encoded},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRtspPublishUrl(streamId) {
|
||||||
|
return `rtsp://127.0.0.1:8554/${streamId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCodec(codec) {
|
||||||
|
return String(codec || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'av1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewId(id, codec) {
|
||||||
|
return `${id}-preview-${sanitizeCodec(codec)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomCameraStream(camera) {
|
||||||
|
if (camera.streamUrl) return camera.streamUrl;
|
||||||
|
const url = String(camera.url || '');
|
||||||
|
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSources() {
|
||||||
|
const rooms = getRoomCameras()
|
||||||
|
.map((camera) => {
|
||||||
|
const streamUrl = getRoomCameraStream(camera);
|
||||||
|
if (!streamUrl) return null;
|
||||||
|
const id = String(camera.id);
|
||||||
|
return {
|
||||||
|
type: 'room',
|
||||||
|
id,
|
||||||
|
label: camera.name || id,
|
||||||
|
inputUrl: streamUrl,
|
||||||
|
outputId: `room/${buildPreviewId(id, PREVIEW_CODEC)}`,
|
||||||
|
bitrateKbps: ROOM_BITRATE_KBPS,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const rovers = roverManager.getRoster().map((rover) => {
|
||||||
|
const id = String(rover.id);
|
||||||
|
return {
|
||||||
|
type: 'rover',
|
||||||
|
id,
|
||||||
|
label: rover.name || id,
|
||||||
|
inputUrl: buildSrtReadUrl(id),
|
||||||
|
outputId: buildPreviewId(id, PREVIEW_CODEC),
|
||||||
|
bitrateKbps: ROVER_BITRATE_KBPS,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [...rooms, ...rovers];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKey(source) {
|
||||||
|
return `${source.type}:${source.id}:${source.outputId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArgs(source) {
|
||||||
|
const gop = Math.max(1, Math.round(GOP_SECONDS * PREVIEW_FPS));
|
||||||
|
const maxrate = Math.floor(source.bitrateKbps * 1.1);
|
||||||
|
const bufsize = Math.max(1, source.bitrateKbps * 2);
|
||||||
|
const codec = PREVIEW_CODEC === 'av1' ? 'libsvtav1' : 'libx264';
|
||||||
|
const extraCodecArgs =
|
||||||
|
codec === 'libx264'
|
||||||
|
? ['-tune', 'zerolatency', '-profile:v', 'baseline', '-sc_threshold', '0']
|
||||||
|
: [];
|
||||||
|
const outputUrl =
|
||||||
|
PREVIEW_TRANSPORT === 'rtsp' ? buildRtspPublishUrl(source.outputId) : buildSrtPublishUrl(source.outputId);
|
||||||
|
const outputArgs =
|
||||||
|
PREVIEW_TRANSPORT === 'rtsp'
|
||||||
|
? ['-f', 'rtsp', '-rtsp_transport', 'tcp']
|
||||||
|
: ['-f', 'mpegts'];
|
||||||
|
return {
|
||||||
|
outputUrl,
|
||||||
|
args: [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'info',
|
||||||
|
'-fflags',
|
||||||
|
'nobuffer',
|
||||||
|
'-flags',
|
||||||
|
'low_delay',
|
||||||
|
'-i',
|
||||||
|
source.inputUrl,
|
||||||
|
'-an',
|
||||||
|
'-vf',
|
||||||
|
`fps=${PREVIEW_FPS},scale=${PREVIEW_WIDTH}:-1`,
|
||||||
|
'-c:v',
|
||||||
|
codec,
|
||||||
|
'-preset',
|
||||||
|
PRESET,
|
||||||
|
...extraCodecArgs,
|
||||||
|
'-g',
|
||||||
|
String(gop),
|
||||||
|
'-keyint_min',
|
||||||
|
String(gop),
|
||||||
|
'-b:v',
|
||||||
|
`${source.bitrateKbps}k`,
|
||||||
|
'-maxrate',
|
||||||
|
`${maxrate}k`,
|
||||||
|
'-bufsize',
|
||||||
|
`${bufsize}k`,
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
...outputArgs,
|
||||||
|
outputUrl,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnRecorder(source) {
|
||||||
|
const key = buildKey(source);
|
||||||
|
if (recorders.has(key)) return;
|
||||||
|
const { args, outputUrl } = buildArgs(source);
|
||||||
|
logger.info('Preview transcoder starting', {
|
||||||
|
key,
|
||||||
|
transport: PREVIEW_TRANSPORT,
|
||||||
|
codec: PREVIEW_CODEC,
|
||||||
|
inputUrl: source.inputUrl,
|
||||||
|
outputUrl,
|
||||||
|
});
|
||||||
|
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
const stderrChunks = [];
|
||||||
|
let stderrSize = 0;
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
if (!chunk || stderrSize > 8192) return;
|
||||||
|
stderrChunks.push(chunk);
|
||||||
|
stderrSize += chunk.length;
|
||||||
|
});
|
||||||
|
recorders.set(key, { proc, source });
|
||||||
|
proc.on('exit', (code, signal) => {
|
||||||
|
recorders.delete(key);
|
||||||
|
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
|
||||||
|
if (stderrBuffer && stderrBuffer.length) {
|
||||||
|
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
|
||||||
|
logger.warn('Preview transcoder stderr', {
|
||||||
|
key,
|
||||||
|
stderrBytes: stderrBuffer.length,
|
||||||
|
stderrPreview: preview || '<non-utf8>',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn('Preview transcoder stderr', { key, stderrBytes: 0 });
|
||||||
|
}
|
||||||
|
if (!ENABLED) return;
|
||||||
|
const delay = 2000;
|
||||||
|
logger.warn('Preview transcoder exited; restarting', { key, code, signal });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!recorders.has(key) && ENABLED) {
|
||||||
|
spawnRecorder(source);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
events.emit('spawn', { key, source });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecorder(key) {
|
||||||
|
const entry = recorders.get(key);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.proc.kill('SIGTERM');
|
||||||
|
recorders.delete(key);
|
||||||
|
events.emit('stop', { key, source: entry.source });
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRecorders() {
|
||||||
|
const sources = listSources();
|
||||||
|
const desiredKeys = new Set();
|
||||||
|
sources.forEach((source) => {
|
||||||
|
const key = buildKey(source);
|
||||||
|
desiredKeys.add(key);
|
||||||
|
if (!recorders.has(key)) {
|
||||||
|
spawnRecorder(source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Array.from(recorders.keys()).forEach((key) => {
|
||||||
|
if (!desiredKeys.has(key)) {
|
||||||
|
stopRecorder(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.info('Preview transcoders synced', { total: desiredKeys.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!ENABLED) {
|
||||||
|
logger.info('Preview transcoders disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncRecorders();
|
||||||
|
if (!syncTimer) {
|
||||||
|
syncTimer = setInterval(syncRecorders, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (syncTimer) {
|
||||||
|
clearInterval(syncTimer);
|
||||||
|
syncTimer = null;
|
||||||
|
}
|
||||||
|
Array.from(recorders.keys()).forEach((key) => stopRecorder(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('rover', () => {
|
||||||
|
if (ENABLED) {
|
||||||
|
syncRecorders();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
roomCameraEvents.on('update', () => {
|
||||||
|
if (ENABLED) {
|
||||||
|
syncRecorders();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
previewEvents: events,
|
||||||
|
syncPreviewTranscoders: syncRecorders,
|
||||||
|
stopPreviewTranscoders: stop,
|
||||||
|
};
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const { sendAlert, COLORS } = require('./alertService');
|
const { sendAlert } = require('./alertService');
|
||||||
|
const ALERT_COLOR = '#ff9800';
|
||||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||||
|
const { publishEvent } = require('./eventBus');
|
||||||
|
|
||||||
const MODES = {
|
const MODES = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
@@ -10,7 +12,7 @@ const MODES = {
|
|||||||
LOCKDOWN: 'lockdown',
|
LOCKDOWN: 'lockdown',
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentMode = MODES.OPEN;
|
let currentMode = MODES.ADMIN;
|
||||||
const modeEvents = new EventEmitter();
|
const modeEvents = new EventEmitter();
|
||||||
|
|
||||||
function canChangeMode(socket, nextMode) {
|
function canChangeMode(socket, nextMode) {
|
||||||
@@ -20,11 +22,12 @@ function canChangeMode(socket, nextMode) {
|
|||||||
return isAdmin(socket);
|
return isAdmin(socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(nextMode, socket) {
|
function setMode(nextMode, socket, options = {}) {
|
||||||
if (!Object.values(MODES).includes(nextMode)) {
|
if (!Object.values(MODES).includes(nextMode)) {
|
||||||
throw new Error(`Unknown mode ${nextMode}`);
|
throw new Error(`Unknown mode ${nextMode}`);
|
||||||
}
|
}
|
||||||
if (!canChangeMode(socket, nextMode)) {
|
const force = Boolean(options.force);
|
||||||
|
if (!force && !canChangeMode(socket, nextMode)) {
|
||||||
throw new Error('Not authorized to change mode');
|
throw new Error('Not authorized to change mode');
|
||||||
}
|
}
|
||||||
if (currentMode === nextMode) {
|
if (currentMode === nextMode) {
|
||||||
@@ -32,10 +35,15 @@ function setMode(nextMode, socket) {
|
|||||||
}
|
}
|
||||||
currentMode = nextMode;
|
currentMode = nextMode;
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: COLORS.info,
|
color: ALERT_COLOR,
|
||||||
title: 'Mode Changed',
|
title: 'Mode Changed',
|
||||||
message: `Server mode set to ${nextMode}`,
|
message: `Server mode set to ${nextMode}`,
|
||||||
});
|
});
|
||||||
|
publishEvent({
|
||||||
|
source: 'modeManager',
|
||||||
|
type: 'mode.changed',
|
||||||
|
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
|
||||||
|
});
|
||||||
modeEvents.emit('change', currentMode);
|
modeEvents.emit('change', currentMode);
|
||||||
io.emit('mode', { mode: currentMode });
|
io.emit('mode', { mode: currentMode });
|
||||||
return currentMode;
|
return currentMode;
|
||||||
@@ -58,7 +66,7 @@ io.on('connection', (socket) => {
|
|||||||
try {
|
try {
|
||||||
setMode(mode, socket);
|
setMode(mode, socket);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message });
|
sendAlert({ color: ALERT_COLOR, title: 'Mode change failed', message: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +1,52 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('nicknameService');
|
||||||
|
const { getRole } = require('./roleService');
|
||||||
|
|
||||||
|
const nicknameEvents = new EventEmitter();
|
||||||
|
|
||||||
|
function sanitizeNickname(raw) {
|
||||||
|
if (typeof raw !== 'string') return '';
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
const sanitized = trimmed.replace(/\*/g, 'nope');
|
||||||
|
return sanitized.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNickname(socket) {
|
||||||
|
return socket?.data?.nickname || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNickname(socket, nickname) {
|
||||||
|
if (!socket) return null;
|
||||||
|
const role = getRole(socket);
|
||||||
|
// if (role === 'spectator') {
|
||||||
|
// throw new Error('Spectators cannot set nicknames');
|
||||||
|
// }
|
||||||
|
const value = sanitizeNickname(nickname);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('Nickname required');
|
||||||
|
}
|
||||||
|
socket.data = socket.data || {};
|
||||||
|
socket.data.nickname = value;
|
||||||
|
nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
|
||||||
|
logger.info('Nickname set', { socketId: socket.id, nickname: value });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('nickname:set', ({ nickname } = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
const value = setNickname(socket, nickname);
|
||||||
|
cb({ success: true, nickname: value });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getNickname,
|
||||||
|
setNickname,
|
||||||
|
nicknameEvents,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
const { execFile } = require('child_process');
|
||||||
|
const fsp = require('fs/promises');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const logger = require('../globals/logger').child('replayBuild');
|
||||||
|
const { replaySegmentsDir, segmentSeconds } = require('./replaySegmentManager');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const REPLAY_DURATION_MS = 20000;
|
||||||
|
const REPLAY_FPS = 15;
|
||||||
|
const MAX_REPLAY_WIDTH = 1280;
|
||||||
|
const MAX_REPLAY_HEIGHT = 720;
|
||||||
|
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
|
||||||
|
|
||||||
|
function buildGridLayout(count) {
|
||||||
|
const cols = Math.ceil(Math.sqrt(count));
|
||||||
|
const rows = Math.ceil(count / cols);
|
||||||
|
return { cols, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampEven(value) {
|
||||||
|
return Math.max(2, Math.floor(value / 2) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScalePadFilter(tileWidth, tileHeight) {
|
||||||
|
return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeMaxFrameSize(paths) {
|
||||||
|
let maxWidth = 0;
|
||||||
|
let maxHeight = 0;
|
||||||
|
for (const filePath of paths) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('ffprobe', [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-select_streams',
|
||||||
|
'v:0',
|
||||||
|
'-show_entries',
|
||||||
|
'stream=width,height',
|
||||||
|
'-of',
|
||||||
|
'csv=p=0',
|
||||||
|
filePath,
|
||||||
|
]);
|
||||||
|
const [widthRaw, heightRaw] = stdout.trim().split(',');
|
||||||
|
const width = Number(widthRaw);
|
||||||
|
const height = Number(heightRaw);
|
||||||
|
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||||
|
maxWidth = Math.max(maxWidth, width);
|
||||||
|
maxHeight = Math.max(maxHeight, height);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to probe replay clip size', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { maxWidth, maxHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLatestSegments(sourceKey, neededCount) {
|
||||||
|
const dir = path.join(replaySegmentsDir, sourceKey);
|
||||||
|
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
const now = Date.now();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
|
||||||
|
const filePath = path.join(dir, entry.name);
|
||||||
|
const stat = await fsp.stat(filePath);
|
||||||
|
if (stat.size < 16 * 1024) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - stat.mtimeMs < segmentSeconds * 1000) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||||
|
}
|
||||||
|
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||||
|
return files.slice(-neededCount).map((file) => file.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReplayVideo({ sources = [] } = {}) {
|
||||||
|
if (!sources.length) {
|
||||||
|
throw new Error('No replay sources selected');
|
||||||
|
}
|
||||||
|
const segmentCount = Math.max(1, Math.ceil(REPLAY_DURATION_MS / (segmentSeconds * 1000)));
|
||||||
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||||
|
try {
|
||||||
|
const clipPaths = [];
|
||||||
|
const usedSources = [];
|
||||||
|
const missingSources = [];
|
||||||
|
for (let i = 0; i < sources.length; i += 1) {
|
||||||
|
const source = sources[i];
|
||||||
|
const key = `${source.type}__${source.id}`;
|
||||||
|
let segmentPaths;
|
||||||
|
try {
|
||||||
|
segmentPaths = await listLatestSegments(key, segmentCount);
|
||||||
|
} catch (err) {
|
||||||
|
missingSources.push({ ...source, reason: err.message || 'missing segments' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segmentPaths.length < segmentCount) {
|
||||||
|
missingSources.push({
|
||||||
|
...source,
|
||||||
|
reason: `only ${segmentPaths.length}/${segmentCount} segments available`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const concatPath = path.join(tmpDir, `concat-${clipPaths.length}.txt`);
|
||||||
|
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
|
||||||
|
await fsp.writeFile(concatPath, concatBody);
|
||||||
|
const clipPath = path.join(tmpDir, `clip-${clipPaths.length}.mp4`);
|
||||||
|
await execFileAsync('ffmpeg', [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-f',
|
||||||
|
'concat',
|
||||||
|
'-safe',
|
||||||
|
'0',
|
||||||
|
'-i',
|
||||||
|
concatPath,
|
||||||
|
'-c',
|
||||||
|
'copy',
|
||||||
|
clipPath,
|
||||||
|
]);
|
||||||
|
clipPaths.push(clipPath);
|
||||||
|
usedSources.push(source);
|
||||||
|
}
|
||||||
|
if (!clipPaths.length) {
|
||||||
|
throw new Error('No replay segments available for selected sources');
|
||||||
|
}
|
||||||
|
if (missingSources.length) {
|
||||||
|
logger.warn('Replay sources missing segments', {
|
||||||
|
missing: missingSources.map((source) => `${source.type}:${source.id}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
|
||||||
|
const layout = buildGridLayout(clipPaths.length);
|
||||||
|
let tileWidth = maxWidth || 640;
|
||||||
|
let tileHeight = maxHeight || 360;
|
||||||
|
let outputWidth = tileWidth * layout.cols;
|
||||||
|
let outputHeight = tileHeight * layout.rows;
|
||||||
|
if (outputWidth > MAX_REPLAY_WIDTH || outputHeight > MAX_REPLAY_HEIGHT) {
|
||||||
|
const scale = Math.min(MAX_REPLAY_WIDTH / outputWidth, MAX_REPLAY_HEIGHT / outputHeight);
|
||||||
|
tileWidth *= scale;
|
||||||
|
tileHeight *= scale;
|
||||||
|
outputWidth = tileWidth * layout.cols;
|
||||||
|
outputHeight = tileHeight * layout.rows;
|
||||||
|
}
|
||||||
|
tileWidth = clampEven(tileWidth);
|
||||||
|
tileHeight = clampEven(tileHeight);
|
||||||
|
outputWidth = clampEven(tileWidth * layout.cols);
|
||||||
|
outputHeight = clampEven(tileHeight * layout.rows);
|
||||||
|
|
||||||
|
const inputArgs = [];
|
||||||
|
const filterParts = [];
|
||||||
|
const layoutParts = [];
|
||||||
|
for (let i = 0; i < clipPaths.length; i += 1) {
|
||||||
|
inputArgs.push('-i', clipPaths[i]);
|
||||||
|
filterParts.push(`[${i}:v]${buildScalePadFilter(tileWidth, tileHeight)}[v${i}]`);
|
||||||
|
const x = (i % layout.cols) * tileWidth;
|
||||||
|
const y = Math.floor(i / layout.cols) * tileHeight;
|
||||||
|
layoutParts.push(`${x}_${y}`);
|
||||||
|
}
|
||||||
|
if (clipPaths.length === 1) {
|
||||||
|
filterParts.push('[v0]null[v]');
|
||||||
|
} else {
|
||||||
|
filterParts.push(
|
||||||
|
`${clipPaths.map((_, i) => `[v${i}]`).join('')}` +
|
||||||
|
`xstack=inputs=${clipPaths.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationSec = Math.max(1, REPLAY_DURATION_MS / 1000);
|
||||||
|
const targetBitrateKbps = Math.max(300, Math.floor((REPLAY_MAX_BYTES * 8) / durationSec / 1000));
|
||||||
|
const maxrateKbps = Math.floor(targetBitrateKbps * 1.1);
|
||||||
|
const bufsizeKbps = Math.floor(targetBitrateKbps * 2);
|
||||||
|
|
||||||
|
const outPath = path.join(tmpDir, 'replay.mp4');
|
||||||
|
await execFileAsync('ffmpeg', [
|
||||||
|
'-y',
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
...inputArgs,
|
||||||
|
'-filter_complex',
|
||||||
|
filterParts.join(';'),
|
||||||
|
'-map',
|
||||||
|
'[v]',
|
||||||
|
'-r',
|
||||||
|
String(REPLAY_FPS),
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-b:v',
|
||||||
|
`${targetBitrateKbps}k`,
|
||||||
|
'-maxrate',
|
||||||
|
`${maxrateKbps}k`,
|
||||||
|
'-bufsize',
|
||||||
|
`${bufsizeKbps}k`,
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
outPath,
|
||||||
|
]);
|
||||||
|
const buffer = await fsp.readFile(outPath);
|
||||||
|
return { buffer, usedSources, missingSources };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to cleanup replay temp dir', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildReplayVideo,
|
||||||
|
};
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fsp = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('../globals/logger').child('replaySegments');
|
||||||
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
|
||||||
|
const SEGMENT_DIR = process.env.REPLAY_SEGMENT_DIR || '/var/lib/replay-segments';
|
||||||
|
const SEGMENT_SECONDS = 2;
|
||||||
|
const BUFFER_SECONDS = 40;
|
||||||
|
const CLEANUP_INTERVAL_MS = 20000;
|
||||||
|
const FPS = 15;
|
||||||
|
const SCALE_WIDTH = 640;
|
||||||
|
const MAX_BYTES = Number.parseInt(process.env.REPLAY_SEGMENT_MAX_BYTES || '0', 10);
|
||||||
|
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||||
|
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||||
|
const ROVER_SNAPSHOT_FPS = 3;
|
||||||
|
|
||||||
|
const recorders = new Map(); // key -> { proc, source }
|
||||||
|
let cleanupTimer = null;
|
||||||
|
|
||||||
|
function sourceKey(source) {
|
||||||
|
return `${source.type}__${source.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomCameraStream(camera) {
|
||||||
|
if (camera.streamUrl) return camera.streamUrl;
|
||||||
|
const url = String(camera.url || '');
|
||||||
|
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSources() {
|
||||||
|
const rooms = getRoomCameras().map((camera) => ({
|
||||||
|
type: 'room',
|
||||||
|
id: String(camera.id),
|
||||||
|
label: camera.name || camera.id,
|
||||||
|
streamUrl: getRoomCameraStream(camera),
|
||||||
|
}));
|
||||||
|
const rovers = roverManager.getRoster().map((rover) => ({
|
||||||
|
type: 'rover',
|
||||||
|
id: String(rover.id),
|
||||||
|
label: rover.name || rover.id,
|
||||||
|
}));
|
||||||
|
return [...rooms, ...rovers];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInputUrl(source) {
|
||||||
|
if (source.type === 'room') {
|
||||||
|
return source.streamUrl || null;
|
||||||
|
}
|
||||||
|
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(source.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(dir) {
|
||||||
|
await fsp.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputPattern(key) {
|
||||||
|
return path.join(SEGMENT_DIR, key, '%Y%m%d-%H%M%S.mp4');
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnRecorder(source) {
|
||||||
|
const key = sourceKey(source);
|
||||||
|
const inputUrl = buildInputUrl(source);
|
||||||
|
if (!inputUrl) {
|
||||||
|
logger.warn('Replay recorder missing input URL', { key, source });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const outPattern = buildOutputPattern(key);
|
||||||
|
const outputSnapshot = source.type === 'rover'
|
||||||
|
? path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`)
|
||||||
|
: null;
|
||||||
|
ensureDir(path.dirname(outPattern))
|
||||||
|
.then(async () => {
|
||||||
|
if (outputSnapshot) {
|
||||||
|
await ensureDir(ROVER_SNAPSHOT_DIR);
|
||||||
|
}
|
||||||
|
const args = [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'info',
|
||||||
|
'-y',
|
||||||
|
'-fflags',
|
||||||
|
'nobuffer',
|
||||||
|
'-flags',
|
||||||
|
'low_delay',
|
||||||
|
'-i',
|
||||||
|
inputUrl,
|
||||||
|
];
|
||||||
|
if (outputSnapshot) {
|
||||||
|
args.push(
|
||||||
|
'-filter_complex',
|
||||||
|
`[0:v]fps=${FPS},scale=${SCALE_WIDTH}:-1[vseg];` +
|
||||||
|
`[0:v]fps=${ROVER_SNAPSHOT_FPS},scale=${SCALE_WIDTH}:-1[vjpg]`,
|
||||||
|
'-map',
|
||||||
|
'[vseg]',
|
||||||
|
'-an',
|
||||||
|
'-c:v:0',
|
||||||
|
'libx264',
|
||||||
|
'-preset',
|
||||||
|
'veryfast',
|
||||||
|
'-tune',
|
||||||
|
'zerolatency',
|
||||||
|
'-g',
|
||||||
|
String(FPS),
|
||||||
|
'-keyint_min',
|
||||||
|
String(FPS),
|
||||||
|
'-sc_threshold',
|
||||||
|
'0',
|
||||||
|
'-f',
|
||||||
|
'segment',
|
||||||
|
'-segment_time',
|
||||||
|
String(SEGMENT_SECONDS),
|
||||||
|
'-segment_format',
|
||||||
|
'mp4',
|
||||||
|
'-reset_timestamps',
|
||||||
|
'1',
|
||||||
|
'-strftime',
|
||||||
|
'1',
|
||||||
|
outPattern,
|
||||||
|
'-map',
|
||||||
|
'[vjpg]',
|
||||||
|
'-an',
|
||||||
|
'-q:v:1',
|
||||||
|
'8',
|
||||||
|
'-f',
|
||||||
|
'image2',
|
||||||
|
'-update',
|
||||||
|
'1',
|
||||||
|
outputSnapshot,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
args.push(
|
||||||
|
'-r',
|
||||||
|
String(FPS),
|
||||||
|
'-vf',
|
||||||
|
`fps=${FPS},scale=${SCALE_WIDTH}:-1`,
|
||||||
|
'-an',
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-preset',
|
||||||
|
'veryfast',
|
||||||
|
'-tune',
|
||||||
|
'zerolatency',
|
||||||
|
'-g',
|
||||||
|
String(FPS),
|
||||||
|
'-keyint_min',
|
||||||
|
String(FPS),
|
||||||
|
'-sc_threshold',
|
||||||
|
'0',
|
||||||
|
'-f',
|
||||||
|
'segment',
|
||||||
|
'-segment_time',
|
||||||
|
String(SEGMENT_SECONDS),
|
||||||
|
'-segment_format',
|
||||||
|
'mp4',
|
||||||
|
'-reset_timestamps',
|
||||||
|
'1',
|
||||||
|
'-strftime',
|
||||||
|
'1',
|
||||||
|
outPattern,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
const stderrChunks = [];
|
||||||
|
let stderrSize = 0;
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
if (!chunk || stderrSize > 8192) return;
|
||||||
|
stderrChunks.push(chunk);
|
||||||
|
stderrSize += chunk.length;
|
||||||
|
});
|
||||||
|
recorders.set(key, { proc, source });
|
||||||
|
proc.on('exit', (code, signal) => {
|
||||||
|
recorders.delete(key);
|
||||||
|
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
|
||||||
|
if (stderrBuffer && stderrBuffer.length) {
|
||||||
|
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
|
||||||
|
logger.warn('Replay recorder stderr', {
|
||||||
|
key,
|
||||||
|
stderrBytes: stderrBuffer.length,
|
||||||
|
stderrPreview: preview || '<non-utf8>',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn('Replay recorder stderr', { key, stderrBytes: 0 });
|
||||||
|
}
|
||||||
|
if (!shouldRecord(source)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = 2000;
|
||||||
|
logger.warn('Replay recorder exited; restarting', { key, code, signal });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!recorders.has(key) && shouldRecord(source)) {
|
||||||
|
spawnRecorder(source);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn('Replay recorder setup failed', { key, err: err.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecorder(key) {
|
||||||
|
const entry = recorders.get(key);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.proc.kill('SIGTERM');
|
||||||
|
recorders.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRecord(source) {
|
||||||
|
if (source.type === 'room') {
|
||||||
|
return Boolean(source.streamUrl);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRecorders() {
|
||||||
|
const sources = listSources();
|
||||||
|
const desiredKeys = new Set();
|
||||||
|
sources.forEach((source) => {
|
||||||
|
if (!shouldRecord(source)) return;
|
||||||
|
const key = sourceKey(source);
|
||||||
|
desiredKeys.add(key);
|
||||||
|
if (!recorders.has(key)) {
|
||||||
|
spawnRecorder(source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Array.from(recorders.keys()).forEach((key) => {
|
||||||
|
if (!desiredKeys.has(key)) {
|
||||||
|
stopRecorder(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupSegments() {
|
||||||
|
try {
|
||||||
|
await ensureDir(SEGMENT_DIR);
|
||||||
|
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
||||||
|
const entries = await fsp.readdir(SEGMENT_DIR, { withFileTypes: true });
|
||||||
|
let totalBytes = 0;
|
||||||
|
const files = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const dirPath = path.join(SEGMENT_DIR, entry.name);
|
||||||
|
const inner = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const file of inner) {
|
||||||
|
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
|
||||||
|
const filePath = path.join(dirPath, file.name);
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
totalBytes += stats.size;
|
||||||
|
files.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size });
|
||||||
|
if (stats.mtimeMs < cutoff) {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remaining = await fsp.readdir(dirPath);
|
||||||
|
if (!remaining.length) {
|
||||||
|
await fsp.rmdir(dirPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MAX_BYTES > 0 && totalBytes > MAX_BYTES) {
|
||||||
|
const overBy = totalBytes - MAX_BYTES;
|
||||||
|
let freed = 0;
|
||||||
|
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||||
|
for (const file of files) {
|
||||||
|
if (freed >= overBy) break;
|
||||||
|
try {
|
||||||
|
await fsp.unlink(file.filePath);
|
||||||
|
freed += file.size;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Replay cleanup failed', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
syncRecorders();
|
||||||
|
cleanupSegments();
|
||||||
|
if (cleanupTimer) clearInterval(cleanupTimer);
|
||||||
|
cleanupTimer = setInterval(cleanupSegments, CLEANUP_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
roomCameraEvents.on('update', () => {
|
||||||
|
syncRecorders();
|
||||||
|
});
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('rover', () => {
|
||||||
|
syncRecorders();
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
replaySegmentsDir: SEGMENT_DIR,
|
||||||
|
segmentSeconds: SEGMENT_SECONDS,
|
||||||
|
bufferSeconds: BUFFER_SECONDS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const COOLDOWN_MS = 10 * 1000;
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
let lastTriggeredAt = null;
|
||||||
|
let lastTriggeredBy = null;
|
||||||
|
|
||||||
|
function getRemainingMs(now = Date.now()) {
|
||||||
|
if (!lastTriggeredAt) return 0;
|
||||||
|
const elapsed = now - lastTriggeredAt;
|
||||||
|
return Math.max(0, COOLDOWN_MS - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
const remainingMs = getRemainingMs();
|
||||||
|
return {
|
||||||
|
cooldownMs: COOLDOWN_MS,
|
||||||
|
lastTriggeredAt,
|
||||||
|
lastTriggeredBy,
|
||||||
|
remainingMs,
|
||||||
|
available: remainingMs === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryTrigger(by = null) {
|
||||||
|
const remainingMs = getRemainingMs();
|
||||||
|
if (remainingMs > 0) {
|
||||||
|
return { ok: false, remainingMs, state: getState() };
|
||||||
|
}
|
||||||
|
lastTriggeredAt = Date.now();
|
||||||
|
lastTriggeredBy = by || null;
|
||||||
|
const state = getState();
|
||||||
|
events.emit('update', { state, by: lastTriggeredBy });
|
||||||
|
return { ok: true, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
tryTriggerReplay: tryTrigger,
|
||||||
|
getReplayState: getState,
|
||||||
|
replayEvents: events,
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('replaySocket');
|
||||||
|
const { getMode, MODES } = require('./modeManager');
|
||||||
|
const { publishEvent } = require('./eventBus');
|
||||||
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
|
const { validateSources, getDefaultWebSources } = require('./replaySourceService');
|
||||||
|
const assignmentService = require('./assignmentService');
|
||||||
|
const { getNickname } = require('./nicknameService');
|
||||||
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const discordConfig = config.discord || {};
|
||||||
|
|
||||||
|
function buildRequesterLabel(socket) {
|
||||||
|
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('replay:trigger', (payload = {}, cb = () => {}) => {
|
||||||
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
|
cb({ error: 'Replay disabled in lockdown', state: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channelId = discordConfig?.channels?.replay || null;
|
||||||
|
if (!channelId) {
|
||||||
|
cb({ error: 'Replay channel not configured', state: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
|
||||||
|
let sources = requestedSources ? validateSources(requestedSources) : [];
|
||||||
|
if (!sources.length) {
|
||||||
|
const assignment = assignmentService.describeAssignment(socket.id);
|
||||||
|
sources = getDefaultWebSources(assignment);
|
||||||
|
}
|
||||||
|
if (!sources.length) {
|
||||||
|
cb({ error: 'No replay sources selected', state: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requester = buildRequesterLabel(socket);
|
||||||
|
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
||||||
|
if (!attempt.ok) {
|
||||||
|
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publishEvent({
|
||||||
|
source: 'replaySocket',
|
||||||
|
type: 'replay.requested',
|
||||||
|
payload: {
|
||||||
|
channelId,
|
||||||
|
requester,
|
||||||
|
sources,
|
||||||
|
requestedBy: { socketId: socket.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info('Replay requested via web', { socketId: socket.id });
|
||||||
|
cb({ success: true, state: attempt.state });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { getRoomCameras } = require('./roomCameraService');
|
||||||
|
|
||||||
|
function getReplaySources() {
|
||||||
|
const roverSources = roverManager.getRoster().map((rover) => ({
|
||||||
|
type: 'rover',
|
||||||
|
id: String(rover.id),
|
||||||
|
label: rover.name || rover.id,
|
||||||
|
}));
|
||||||
|
const roomSources = getRoomCameras().map((camera) => ({
|
||||||
|
type: 'room',
|
||||||
|
id: String(camera.id),
|
||||||
|
label: camera.name || camera.id,
|
||||||
|
}));
|
||||||
|
return [...roverSources, ...roomSources];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSource(entry) {
|
||||||
|
if (!entry) return null;
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
const [type, id] = entry.split(':');
|
||||||
|
if (!type || !id) return null;
|
||||||
|
return { type, id: String(id) };
|
||||||
|
}
|
||||||
|
if (typeof entry === 'object') {
|
||||||
|
if (entry.type && entry.id) {
|
||||||
|
return { type: entry.type, id: String(entry.id) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSources(list = []) {
|
||||||
|
const allowed = new Map();
|
||||||
|
getReplaySources().forEach((source) => {
|
||||||
|
allowed.set(`${source.type}:${source.id}`, source);
|
||||||
|
});
|
||||||
|
const unique = new Map();
|
||||||
|
(Array.isArray(list) ? list : []).forEach((entry) => {
|
||||||
|
const normalized = normalizeSource(entry);
|
||||||
|
if (!normalized) return;
|
||||||
|
const key = `${normalized.type}:${normalized.id}`;
|
||||||
|
const source = allowed.get(key);
|
||||||
|
if (!source) return;
|
||||||
|
unique.set(key, { type: source.type, id: source.id, label: source.label });
|
||||||
|
});
|
||||||
|
return Array.from(unique.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultWebSources(assignment = {}) {
|
||||||
|
if (assignment?.roverId) {
|
||||||
|
const id = String(assignment.roverId);
|
||||||
|
const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id);
|
||||||
|
return [{ type: 'rover', id, label: match?.label || id }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultDiscordSources() {
|
||||||
|
return getRoomCameras().map((camera) => ({
|
||||||
|
type: 'room',
|
||||||
|
id: String(camera.id),
|
||||||
|
label: camera.name || camera.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getReplaySources,
|
||||||
|
validateSources,
|
||||||
|
getDefaultWebSources,
|
||||||
|
getDefaultDiscordSources,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user