mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
481
Commits
ban-system
...
deadcode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ca9776cc | ||
|
|
3de42412dd | ||
|
|
7148e41828 | ||
|
|
e58e5503e8 | ||
|
|
a497c8a630 | ||
|
|
0141af2ed6 | ||
|
|
05cfe42860 | ||
|
|
4971edbb59 | ||
|
|
b2cae9bfa4 | ||
|
|
def38ad5a9 | ||
|
|
e8c4ffc543 | ||
|
|
33f1e66b62 | ||
|
|
13885fff4c | ||
|
|
21215260ce | ||
|
|
a99102dc2a | ||
|
|
257e4d037d | ||
|
|
85e42851f3 | ||
|
|
49987c445f | ||
|
|
f4eab8776f | ||
|
|
eb73ff225c | ||
|
|
42770fbfe6 | ||
|
|
3a9015753c | ||
|
|
0ea96613b0 | ||
|
|
a66a68d7eb | ||
|
|
c8685e9e0e | ||
|
|
8e49a106fb | ||
|
|
1175ea7deb | ||
|
|
37ab5ed278 | ||
|
|
8a059a4642 | ||
|
|
41aff63ae6 | ||
|
|
52addabeed | ||
|
|
a979e8c905 | ||
|
|
26d59b6650 | ||
|
|
d887997a5a | ||
|
|
cf7bb5c337 | ||
|
|
9ebf86cdfa | ||
|
|
9504306af6 | ||
|
|
3487fd200c | ||
|
|
15fedc7c77 | ||
|
|
5e2c836cf2 | ||
|
|
7eb33c9af2 | ||
|
|
5b48b10971 | ||
|
|
e97b623b43 | ||
|
|
906368d45e | ||
|
|
850f89c8ed | ||
|
|
32e4afdbe8 | ||
|
|
4003379dfb | ||
|
|
837701eee8 | ||
|
|
2f570d92d7 | ||
|
|
ba4b6cfc78 | ||
|
|
53cf39e7fb | ||
|
|
e8f6fb9654 | ||
|
|
0f97382f42 | ||
|
|
03cd8c00dd | ||
|
|
df7b933f41 | ||
|
|
9c287f068a | ||
|
|
6280724f6e | ||
|
|
4890b4e5c6 | ||
|
|
6de19c69df | ||
|
|
76fc1c02d5 | ||
|
|
b76405ed46 | ||
|
|
2833175fca | ||
|
|
fa981ef298 | ||
|
|
7180d3352d | ||
|
|
458199ad11 | ||
|
|
3bb16c140e | ||
|
|
8550c85106 | ||
|
|
244ff572c4 | ||
|
|
aa530ef67d | ||
|
|
e92d670f80 | ||
|
|
c37a7965ff | ||
|
|
c01456409d | ||
|
|
b8bcd49473 | ||
|
|
a8dd71193a | ||
|
|
53723e2f1e | ||
|
|
e3e563feee | ||
|
|
6523ea7417 | ||
|
|
02d5d6ca13 | ||
|
|
7e7eb97ff6 | ||
|
|
b491d91ec5 | ||
|
|
eefe434607 | ||
|
|
b2a2296d98 | ||
|
|
962d2ae61e | ||
|
|
ff877f04cc | ||
|
|
963499fa8e | ||
|
|
0ae60ef4f9 | ||
|
|
a3067a06d9 | ||
|
|
09c7a0c813 | ||
|
|
d7057289ab | ||
|
|
5654702e51 | ||
|
|
90e3c9873b | ||
|
|
da56086b21 | ||
|
|
40e625793e | ||
|
|
97196ef202 | ||
|
|
c31fffec20 | ||
|
|
9e24ed2ddf | ||
|
|
91a3272780 | ||
|
|
943fa11f7e | ||
|
|
9bf449ca19 | ||
|
|
01a8d044e7 | ||
|
|
fed0cab0b8 | ||
|
|
ef5c6fab89 | ||
|
|
fcfcdc907a | ||
|
|
810c465040 | ||
|
|
9be91c22bd | ||
|
|
88acba5e1a | ||
|
|
d8a01f9b60 | ||
|
|
f26735b9e5 | ||
|
|
2e140d1bd5 | ||
|
|
b716e40f53 | ||
|
|
9b80b0d248 | ||
|
|
5ebf5fa580 | ||
|
|
0003338ecd | ||
|
|
40e55240e4 | ||
|
|
d7f34af66f | ||
|
|
9b9fa13eaf | ||
|
|
bc6770f407 | ||
|
|
e2bd29e2c2 | ||
|
|
2eafcce95b | ||
|
|
c20c22eafc | ||
|
|
ea3c030a21 | ||
|
|
a4400655c8 | ||
|
|
5036bbfa07 | ||
|
|
85db918085 | ||
|
|
dba714cc42 | ||
|
|
ccd502c0f9 | ||
|
|
d47face038 | ||
|
|
b0f62e1cad | ||
|
|
053dfc6396 | ||
|
|
4a3ace007e | ||
|
|
019147a6ce | ||
|
|
fc229fcf62 | ||
|
|
50f6569b22 | ||
|
|
8deee70d4a | ||
|
|
49f801330f | ||
|
|
f13b85109e | ||
|
|
07ad9ab253 | ||
|
|
86eb7e628e | ||
|
|
c31408611e | ||
|
|
bff79b5f8c | ||
|
|
3d809fd91c | ||
|
|
7b0f7cbc56 | ||
|
|
7e13d44458 | ||
|
|
4dc930f64b | ||
|
|
3a2b8b3557 | ||
|
|
1d6bc24067 | ||
|
|
13e9ebc909 | ||
|
|
9b59718a37 | ||
|
|
1607968cee | ||
|
|
b0cd29c075 | ||
|
|
928c1112a1 | ||
|
|
83d9cd51de | ||
|
|
aaeb173cea | ||
|
|
850b271154 | ||
|
|
b054b93c96 | ||
|
|
630e112733 | ||
|
|
2439d5df19 | ||
|
|
4bd3094351 | ||
|
|
0eb43738cb | ||
|
|
a0ef334c8c | ||
|
|
bef54d0f2e | ||
|
|
c4a80202fc | ||
|
|
16ae430090 | ||
|
|
ae24793057 | ||
|
|
32faa5866d | ||
|
|
0e91f38f1e | ||
|
|
1ea5929f79 | ||
|
|
76e966abec | ||
|
|
07b1e8bd86 | ||
|
|
bdbf3c0138 | ||
|
|
286012809f | ||
|
|
f743f767a9 | ||
|
|
92fde05847 | ||
|
|
b92034ecab | ||
|
|
aa1a115203 | ||
|
|
a5705ede2b | ||
|
|
ae6d2df0a2 | ||
|
|
7847f5e79c | ||
|
|
f565d886a8 | ||
|
|
304c984423 | ||
|
|
c28cb4bb20 | ||
|
|
374d1448ac | ||
|
|
de3e32e0f0 | ||
|
|
47dc4e06af | ||
|
|
286cc1c62a | ||
|
|
e63eddc314 | ||
|
|
ed22c5c393 | ||
|
|
863c7daafb | ||
|
|
3a5c14412d | ||
|
|
41bbd7bd98 | ||
|
|
1359c701bb | ||
|
|
e54b8e34ab | ||
|
|
f23a0e15c3 | ||
|
|
0c93aacdf6 | ||
|
|
d3f263c216 | ||
|
|
9b732429a4 | ||
|
|
ff3c5321df | ||
|
|
8fd435b3b4 | ||
|
|
73524990af | ||
|
|
b46108a750 | ||
|
|
adb13fc69c | ||
|
|
aee4f61976 | ||
|
|
5438873846 | ||
|
|
52352bd692 | ||
|
|
e974f4f90f | ||
|
|
1bdae74ee5 | ||
|
|
3baf928402 | ||
|
|
ee08408ccd | ||
|
|
3534c8ef14 | ||
|
|
f4182073ee | ||
|
|
cd70258a21 | ||
|
|
a4e4921931 | ||
|
|
b76c5010b4 | ||
|
|
e5e8e9ed0f | ||
|
|
3513a11068 | ||
|
|
fe8d3df421 | ||
|
|
388efd1f47 | ||
|
|
71e2e18c13 | ||
|
|
6e764f70bc | ||
|
|
214eb47d96 | ||
|
|
49a50a97ed | ||
|
|
33900e79f6 | ||
|
|
7aa648240c | ||
|
|
353efeeffd | ||
|
|
8ed617305d | ||
|
|
5e428b278a | ||
|
|
985e63bab2 | ||
|
|
9a83433f08 | ||
|
|
1e9a6b2e19 | ||
|
|
f72a1628aa | ||
|
|
85fcaedc6a | ||
|
|
c220eb4022 | ||
|
|
998933f4c2 | ||
|
|
904f28eeef | ||
|
|
d74879caa9 | ||
|
|
b0a11541bd | ||
|
|
24d0242364 | ||
|
|
af7782a233 | ||
|
|
b32cc7ef8f | ||
|
|
766f7130e0 | ||
|
|
3127e02f8c | ||
|
|
895960867c | ||
|
|
3af21846c8 | ||
|
|
e0167939d7 | ||
|
|
ebb400adc4 | ||
|
|
463bd89dd8 | ||
|
|
e311e67fc9 | ||
|
|
20227cd0f8 | ||
|
|
ad1b6b48f8 | ||
|
|
626b48f777 | ||
|
|
f112f74db0 | ||
|
|
cc1e887705 | ||
|
|
81b0638d6f | ||
|
|
aeb4b29b88 | ||
|
|
54f8684169 | ||
|
|
36a6c07f95 | ||
|
|
d6da071fde | ||
|
|
9b1251c16d | ||
|
|
e3b428f9ab | ||
|
|
cb97e73987 | ||
|
|
1b7885e74b | ||
|
|
4542c9e6ae | ||
|
|
a58685fa91 | ||
|
|
140ebb5996 | ||
|
|
76c17abdb6 | ||
|
|
f364e6ac60 | ||
|
|
57532f53d0 | ||
|
|
38b76559f4 | ||
|
|
1eb1a33e88 | ||
|
|
ab9d388392 | ||
|
|
cc6e1fde56 | ||
|
|
e2d9a1759b | ||
|
|
dbfab6aa26 | ||
|
|
c729631104 | ||
|
|
7ad3dc7015 | ||
|
|
5b8704ccb3 | ||
|
|
73ef6261ed | ||
|
|
93204ec1f1 | ||
|
|
618fd3e4de | ||
|
|
f5d7d2d82c | ||
|
|
7934e1faf5 | ||
|
|
f63db3fdd7 | ||
|
|
0e4954fadf | ||
|
|
c44632ed4d | ||
|
|
9f1efc6b91 | ||
|
|
ad664f2d32 | ||
|
|
fec9782e3b | ||
|
|
84eaab3c9c | ||
|
|
aaa93847e1 | ||
|
|
4aa53e8f80 | ||
|
|
74a758ab7c | ||
|
|
182a49903f | ||
|
|
941b9889c9 | ||
|
|
6d40ec070e | ||
|
|
169ccdbcf8 | ||
|
|
a2098f4dfc | ||
|
|
3070153052 | ||
|
|
167582b9b0 | ||
|
|
95972af558 | ||
|
|
2ea222355f | ||
|
|
529ae62ad0 | ||
|
|
3c6de7c040 | ||
|
|
3007bab54d | ||
|
|
38e23b0adf | ||
|
|
181fe436ff | ||
|
|
b7f5967c9f | ||
|
|
e53fb5cb92 | ||
|
|
325fdc05ea | ||
|
|
c4532f5825 | ||
|
|
d952b7ad90 | ||
|
|
13da673495 | ||
|
|
30d0f0c686 | ||
|
|
7a21a53ea7 | ||
|
|
4fd7f15292 | ||
|
|
1350d7c395 | ||
|
|
632d474e61 | ||
|
|
84a0b02c18 | ||
|
|
a44d4aed03 | ||
|
|
fafe167571 | ||
|
|
e73b094c21 | ||
|
|
23825d51d2 | ||
|
|
3cf3228b6f | ||
|
|
99b10f7310 | ||
|
|
ae52dc8335 | ||
|
|
7942e61a40 | ||
|
|
82fe62b835 | ||
|
|
12ce7c1344 | ||
|
|
469c52cf7f | ||
|
|
1ac6c17a8e | ||
|
|
231245a9fd | ||
|
|
e30f7272cd | ||
|
|
08fc278494 | ||
|
|
32f5cc29d8 | ||
|
|
985a2f966a | ||
|
|
e9e75b65ef | ||
|
|
bbd088f9e1 | ||
|
|
4284014578 | ||
|
|
d34c107b1a | ||
|
|
5a575aeab4 | ||
|
|
4d953f3cad | ||
|
|
09c7da715e | ||
|
|
9d58983f59 | ||
|
|
7afc79add0 | ||
|
|
5d3652468f | ||
|
|
04bedc7e5c | ||
|
|
a687d191c1 | ||
|
|
0d3b3b0995 | ||
|
|
65094ea5dc | ||
|
|
643557afc6 | ||
|
|
5a7ce8ae69 | ||
|
|
7e233e48b2 | ||
|
|
0a65c43eb0 | ||
|
|
30671332af | ||
|
|
20e89839fe | ||
|
|
18c0d0fc8d | ||
|
|
b22ba216db | ||
|
|
ad31749813 | ||
|
|
9e3bce6ec3 | ||
|
|
6c3ae17aeb | ||
|
|
965a27f3f2 | ||
|
|
2198c64e00 | ||
|
|
1b871a9ad1 | ||
|
|
27e36206c2 | ||
|
|
6312f19e3c | ||
|
|
112391cbd1 | ||
|
|
1f05a7b79c | ||
|
|
c5907d8487 | ||
|
|
47f3e7e654 | ||
|
|
679e901ec6 | ||
|
|
4af0deb1dd | ||
|
|
d38503d19c | ||
|
|
f7170c6b49 | ||
|
|
62994d07cd | ||
|
|
0af84084c9 | ||
|
|
8c24c84e59 | ||
|
|
9647a71d49 | ||
|
|
315ce61ef7 | ||
|
|
0f31a662ee | ||
|
|
e44d0f7b93 | ||
|
|
49fc19f044 | ||
|
|
bb55e90464 | ||
|
|
93971f37fe | ||
|
|
46de9b71f3 | ||
|
|
1d97756aef | ||
|
|
42c885af28 | ||
|
|
da79a62db6 | ||
|
|
d3b35506d9 | ||
|
|
12a787b7c6 | ||
|
|
bf7d54c4f9 | ||
|
|
abd337f723 | ||
|
|
d3a17608af | ||
|
|
ed93bbf4d6 | ||
|
|
d2904f067c | ||
|
|
a76062b873 | ||
|
|
32ec8e1aa3 | ||
|
|
657189e979 | ||
|
|
cc92174d79 | ||
|
|
eefe6f01bb | ||
|
|
4ccc7dfae6 | ||
|
|
8313f5da66 | ||
|
|
d5978c5e3e | ||
|
|
f1c6ab44ce | ||
|
|
d165f69b6f | ||
|
|
438fff0902 | ||
|
|
7e772ab607 | ||
|
|
40e21d4ac5 | ||
|
|
eb52c8c6ac | ||
|
|
1e1a6b6b01 | ||
|
|
dcf7208c08 | ||
|
|
0c3b3aa711 | ||
|
|
42c99410f4 | ||
|
|
1ac46f4a03 | ||
|
|
8ea0f7be70 | ||
|
|
0c992e2457 | ||
|
|
1842177a6d | ||
|
|
c9a19d46f7 | ||
|
|
cf0c12cff7 | ||
|
|
b4c122d914 | ||
|
|
3cfb2683ef | ||
|
|
cb5e5ae755 | ||
|
|
d3852dd86b | ||
|
|
7c1b290df3 | ||
|
|
133b433255 | ||
|
|
d64e99b674 | ||
|
|
79da247dbe | ||
|
|
01cad4515f | ||
|
|
dc92785f19 | ||
|
|
00d1edd11b | ||
|
|
c24ae71e63 | ||
|
|
92c9ae4834 | ||
|
|
5916520b1a | ||
|
|
7eeb9d13e4 | ||
|
|
3fdb1c05e5 | ||
|
|
090d9d6982 | ||
|
|
b7d636c70a | ||
|
|
92ab763b27 | ||
|
|
43dc3df901 | ||
|
|
3997127e5e | ||
|
|
c60cae75a4 | ||
|
|
4f6530badf | ||
|
|
fd06f23de4 | ||
|
|
ca3d95319d | ||
|
|
bdb4f41e93 | ||
|
|
a5f33c0a92 | ||
|
|
a4d6f55576 | ||
|
|
7216d9ba51 | ||
|
|
be4c6260ae | ||
|
|
5e94fc7283 | ||
|
|
bf05d07092 | ||
|
|
c5ae78dcab | ||
|
|
33d329e2de | ||
|
|
690f38972f | ||
|
|
77b5484381 | ||
|
|
ccf0eeb1aa | ||
|
|
d87ec55802 | ||
|
|
2cc0ce962f | ||
|
|
d2722e40cc | ||
|
|
ae81f8914f | ||
|
|
7e850dab22 | ||
|
|
f3cc73f178 | ||
|
|
eea1774549 | ||
|
|
7dbb657dcd | ||
|
|
48e4ddd090 | ||
|
|
f7677a443a | ||
|
|
482e7c1e6b | ||
|
|
b275d5a08c | ||
|
|
2062b4888c | ||
|
|
2671247347 | ||
|
|
3f34196418 | ||
|
|
689d914b4e | ||
|
|
b6b1bee1dd | ||
|
|
522ec335bf | ||
|
|
b730c98704 | ||
|
|
d7833dbba9 | ||
|
|
536e4fe32e | ||
|
|
2494f6e276 | ||
|
|
2dda137855 | ||
|
|
8341328b51 | ||
|
|
b718de1e88 | ||
|
|
ec4e969340 | ||
|
|
dd55514cb4 |
+2
-1
@@ -14,5 +14,6 @@ server/config.yaml
|
|||||||
server/package-lock.json
|
server/package-lock.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
server/data/discord-guilds.json
|
server/data/discord-guilds.json
|
||||||
server/data/community-goal.json
|
server/data/global-objective.json
|
||||||
|
server/data/admin-reason.json
|
||||||
server/data
|
server/data
|
||||||
|
|||||||
@@ -1,89 +1,7 @@
|
|||||||
# Multi Roomba Rover
|
# Multi Roomba Rover
|
||||||
|
A system for controlling create 2 compatible roombas through a webpage.
|
||||||
|
|
||||||
a remake of my RoombaRover project with a decentralized and embedded approach
|
Docs coming "soon"
|
||||||
supports multiple roombas
|
|
||||||
|
|
||||||
on each roomba:
|
## Basic installation
|
||||||
- a raspberry pi zero 2 w
|
-
|
||||||
- a raspberry pi camera
|
|
||||||
- roomba's serial port hooked up to the built in serial port on the raspberry pi
|
|
||||||
|
|
||||||
pi provisioning:
|
|
||||||
- enable serial port
|
|
||||||
- disable wifi powersave
|
|
||||||
- disable bluetooth
|
|
||||||
|
|
||||||
## Repo layout
|
|
||||||
|
|
||||||
- `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets.
|
|
||||||
- `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`.
|
|
||||||
- `pi/bin` / `pi/systemd`: helper scripts + systemd units for the rover (roverd itself plus the SRT video publisher that streams camera feeds to the server).
|
|
||||||
- `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX).
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# build the Pi agent (armv7)
|
|
||||||
cd pi/roverd
|
|
||||||
mkdir -p ../../dist
|
|
||||||
make pi-build
|
|
||||||
|
|
||||||
# start the server + UI
|
|
||||||
cd ../../server
|
|
||||||
npm install
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
Need a fake rover or multi-rover testing without hardware? Use the dummy build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pi/roverd
|
|
||||||
mkdir -p ../../dist
|
|
||||||
make dummy
|
|
||||||
./../../dist/roverd-dummy -config ./roverd.sample.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
The dummy binary connects to the Node server, emits simulated sensor frames, and logs every command it receives, so you can spin up as many virtual rovers as you’d like on your dev machine.
|
|
||||||
|
|
||||||
## Admin config & authentication
|
|
||||||
|
|
||||||
Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UI’s login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks can’t grant extra control.
|
|
||||||
|
|
||||||
Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it installs roverd plus the SRT video publisher service):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/MultiRoombaRover
|
|
||||||
sudo ./pi/install_roverd.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set `name` to the rover’s ID, and (optionally) override `media.publishUrl` if your control server isn’t `192.168.0.86`. The video publisher service (`video-publisher.service`) captures the Pi camera with `rpicam-vid`/`libcamera-vid`, pipes the raw H264 into the stock FFmpeg binary, and publishes via SRT to the server’s mediaMTX instance at `srt://<server>:9000?streamid=#!::r=<name>,m=publish`. The installer now just pulls `libcamera-apps` + `ffmpeg` from apt (no custom build). Use the “Restart Camera” button if you enable media management so roverd can bounce the publisher service remotely.
|
|
||||||
Heads-up: the BRC pulser now uses libgpiod; make sure the `roverd` service account is in the `gpio` group (or otherwise allowed to access `/dev/gpiochip*`) and set `brc.gpioChip` if your hardware exposes a different chip name.
|
|
||||||
|
|
||||||
## Fedora server deployment
|
|
||||||
|
|
||||||
Run the installer from inside the `server/` directory after cloning the repo onto your Fedora 43 Server box:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/MultiRoombaRover/server
|
|
||||||
sudo ./install_server.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The script must be executed via `sudo` from the user that owns the repo. It will:
|
|
||||||
|
|
||||||
- install Node.js/npm plus curl/tar
|
|
||||||
- run `npm install --production`
|
|
||||||
- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins + `media.whepBaseUrl`)
|
|
||||||
- download mediaMTX v1.15.3 and drop it into `/usr/local/bin`
|
|
||||||
- write `/etc/mediamtx/mediamtx.yml` from `server/mediamtx/mediamtx.yml` (SRT ingest on :9000, open ingest, viewer auth webhook at `/mediamtx/auth`)
|
|
||||||
- create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly
|
|
||||||
|
|
||||||
Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically.
|
|
||||||
|
|
||||||
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (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
|
|
||||||
|
|
||||||
- Every `video:request` returns `{ url, token }`. The browser posts the SDP offer to `url` and includes `Authorization: Basic base64(token:token)`. mediaMTX forwards the username (`token`) to `/mediamtx/auth`, which checks the socket’s permissions (driver assignment, admin/spectator role, lockdown state) and either returns 200 or 401—no query parameters are involved anymore.
|
|
||||||
- To see what mediaMTX is ingesting from the Pis, run `npm run check:media` (or `node scripts/checkMedia.js`). It hits `/v3/paths/list` and prints each rover’s `ready` state and byte counters so you can instantly spot publish issues.
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
|
config.h
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
This directory is intended for project header files.
|
||||||
|
|
||||||
|
A header file is a file containing C declarations and macro definitions
|
||||||
|
to be shared between several project source files. You request the use of a
|
||||||
|
header file in your project source file (C, C++, etc) located in `src` folder
|
||||||
|
by including it, with the C preprocessing directive `#include'.
|
||||||
|
|
||||||
|
```src/main.c
|
||||||
|
|
||||||
|
#include "header.h"
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including a header file produces the same results as copying the header file
|
||||||
|
into each source file that needs it. Such copying would be time-consuming
|
||||||
|
and error-prone. With a header file, the related declarations appear
|
||||||
|
in only one place. If they need to be changed, they can be changed in one
|
||||||
|
place, and programs that include the header file will automatically use the
|
||||||
|
new version when next recompiled. The header file eliminates the labor of
|
||||||
|
finding and changing all the copies as well as the risk that a failure to
|
||||||
|
find one copy will result in inconsistencies within a program.
|
||||||
|
|
||||||
|
In C, the convention is to give header files names that end with `.h'.
|
||||||
|
|
||||||
|
Read more about using header files in official GCC documentation:
|
||||||
|
|
||||||
|
* Include Syntax
|
||||||
|
* Include Operation
|
||||||
|
* Once-Only Headers
|
||||||
|
* Computed Includes
|
||||||
|
|
||||||
|
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
This directory is intended for project specific (private) libraries.
|
||||||
|
PlatformIO will compile them to static libraries and link into the executable file.
|
||||||
|
|
||||||
|
The source code of each library should be placed in a separate directory
|
||||||
|
("lib/your_library_name/[Code]").
|
||||||
|
|
||||||
|
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||||
|
|
||||||
|
|--lib
|
||||||
|
| |
|
||||||
|
| |--Bar
|
||||||
|
| | |--docs
|
||||||
|
| | |--examples
|
||||||
|
| | |--src
|
||||||
|
| | |- Bar.c
|
||||||
|
| | |- Bar.h
|
||||||
|
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||||
|
| |
|
||||||
|
| |--Foo
|
||||||
|
| | |- Foo.c
|
||||||
|
| | |- Foo.h
|
||||||
|
| |
|
||||||
|
| |- README --> THIS FILE
|
||||||
|
|
|
||||||
|
|- platformio.ini
|
||||||
|
|--src
|
||||||
|
|- main.c
|
||||||
|
|
||||||
|
Example contents of `src/main.c` using Foo and Bar:
|
||||||
|
```
|
||||||
|
#include <Foo.h>
|
||||||
|
#include <Bar.h>
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||||
|
libraries by scanning project source files.
|
||||||
|
|
||||||
|
More information about PlatformIO Library Dependency Finder
|
||||||
|
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
; PlatformIO Project Configuration File
|
||||||
|
;
|
||||||
|
; Build options: build flags, source filter
|
||||||
|
; Upload options: custom upload port, speed and extra flags
|
||||||
|
; Library options: dependencies, extra library storages
|
||||||
|
; Advanced options: extra scripting
|
||||||
|
;
|
||||||
|
; Please visit documentation for the other options and examples
|
||||||
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
|
[env:seeed_xiao_esp32s3]
|
||||||
|
platform = espressif32
|
||||||
|
board = esp32-s3-devkitc-1
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#define WIFI_SSID "wifissid"
|
||||||
|
#define WIFI_PASSWORD "wifipassword"
|
||||||
|
#define SERVER_URL "http://192.168.0.86:8080/buttonbox/press"
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <HTTPClient.h>
|
||||||
|
|
||||||
|
#include <config.h>
|
||||||
|
|
||||||
|
// button pin defs
|
||||||
|
const int buttonPins[] = {15, 16, 17, 18};
|
||||||
|
const int buttonCount = sizeof(buttonPins) / sizeof(buttonPins[0]);
|
||||||
|
const int beeperPin = 13;
|
||||||
|
|
||||||
|
// Using INPUT_PULLUP means idle = HIGH, pressed = LOW (button to GND).
|
||||||
|
const int buttonPressedState = LOW;
|
||||||
|
const unsigned long debounceMs = 40;
|
||||||
|
const int toneDurationMs = 90;
|
||||||
|
|
||||||
|
// One tone per button (1-4).
|
||||||
|
const int buttonTonesHz[buttonCount] = {262, 330, 392, 523};
|
||||||
|
|
||||||
|
int lastStableState[buttonCount];
|
||||||
|
int lastReading[buttonCount];
|
||||||
|
unsigned long lastDebounceTime[buttonCount];
|
||||||
|
|
||||||
|
void PlayButtonTone(int buttonNumber) {
|
||||||
|
if (buttonNumber < 1 || buttonNumber > buttonCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int frequencyHz = buttonTonesHz[buttonNumber - 1];
|
||||||
|
tone(beeperPin, frequencyHz, toneDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// button request function
|
||||||
|
bool SendButtonPressRequest(int buttonNumber) {
|
||||||
|
Serial.println("attempting to send button press");
|
||||||
|
bool success = false;
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(SERVER_URL);
|
||||||
|
http.addHeader("Content-Type", "text/plain");
|
||||||
|
|
||||||
|
int httpResponseCode = http.POST(String(buttonNumber));
|
||||||
|
|
||||||
|
if (httpResponseCode > 0) {
|
||||||
|
Serial.println("http response code: ");
|
||||||
|
Serial.print(httpResponseCode);
|
||||||
|
Serial.println(http.getString());
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
Serial.println("HTTP ERROR!!! ");
|
||||||
|
Serial.print(httpResponseCode);
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
http.end();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Serial.println("Not ocnnectec to wifi! cant send request!");
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||||
|
while (WiFi.status() != WL_CONNECTED) {
|
||||||
|
delay(100);
|
||||||
|
Serial.print("wifi connecting... ");
|
||||||
|
}
|
||||||
|
Serial.println("WIFI CONNENCTED !!!! :3");
|
||||||
|
|
||||||
|
for (int i = 0; i < buttonCount; i++) {
|
||||||
|
pinMode(buttonPins[i], INPUT_PULLUP);
|
||||||
|
int initial = digitalRead(buttonPins[i]);
|
||||||
|
lastStableState[i] = initial;
|
||||||
|
lastReading[i] = initial;
|
||||||
|
lastDebounceTime[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pinMode(beeperPin, OUTPUT);
|
||||||
|
noTone(beeperPin);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
for (int i = 0; i < buttonCount; i++) {
|
||||||
|
int reading = digitalRead(buttonPins[i]);
|
||||||
|
|
||||||
|
if (reading != lastReading[i]) {
|
||||||
|
lastDebounceTime[i] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((now - lastDebounceTime[i]) > debounceMs) {
|
||||||
|
if (reading != lastStableState[i]) {
|
||||||
|
lastStableState[i] = reading;
|
||||||
|
|
||||||
|
// Trigger once on press edge.
|
||||||
|
if (reading == buttonPressedState) {
|
||||||
|
if (SendButtonPressRequest(i + 1)) {
|
||||||
|
PlayButtonTone(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReading[i] = reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
|
Unit Testing is a software testing method by which individual units of
|
||||||
|
source code, sets of one or more MCU program modules together with associated
|
||||||
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
|
in the development cycle.
|
||||||
|
|
||||||
|
More information about PlatformIO Unit Testing:
|
||||||
|
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,112 +0,0 @@
|
|||||||
# Pi Deployment Guide
|
|
||||||
|
|
||||||
## Tooling
|
|
||||||
|
|
||||||
Fedora 43:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install golang libgpiod
|
|
||||||
```
|
|
||||||
|
|
||||||
Cross-compiling roverd for Pi Zero 2 W (ARMv7):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pi/roverd
|
|
||||||
mkdir -p ../../dist
|
|
||||||
make pi-build
|
|
||||||
```
|
|
||||||
|
|
||||||
The binary is placed in `dist/roverd` (relative to the repo root).
|
|
||||||
|
|
||||||
### Dummy rover build (for local testing)
|
|
||||||
|
|
||||||
If you need extra “virtual” rovers on your laptop/CI box, build the dummy binary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pi/roverd
|
|
||||||
mkdir -p ../../dist
|
|
||||||
make dummy
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the resulting `dist/roverd-dummy` on any machine; it will connect to the server, stream fake Group 100 sensor data, and log drive commands so you can test multi-rover features without additional hardware.
|
|
||||||
|
|
||||||
## Automated installation (recommended)
|
|
||||||
|
|
||||||
Install the Raspberry Pi camera helpers (Bookworm ships them):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install libcamera-apps
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the binary (and repo) are on the Pi, run the helper script from the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/MultiRoombaRover
|
|
||||||
sudo ./pi/install_roverd.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
What the script does:
|
|
||||||
|
|
||||||
- creates the `roverd` service account (dialout/gpio/video groups) if missing and installs `/usr/local/bin/roverd`
|
|
||||||
- copies `pi/roverd/roverd.sample.yaml` to `/etc/roverd.yaml` if the file is absent (existing configs are left untouched)
|
|
||||||
- installs/enables the `roverd.service` systemd unit, restarting it automatically when a config already exists
|
|
||||||
- installs `/usr/local/bin/video-publisher`, drops `video-publisher.service`, and enables it so video is published automatically on boot
|
|
||||||
|
|
||||||
Flags:
|
|
||||||
|
|
||||||
| Flag | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) |
|
|
||||||
| `-c PATH` | seed `/etc/roverd.yaml` from another template |
|
|
||||||
|
|
||||||
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish…` (the host comes from `serverUrl`). 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.
|
|
||||||
## Manual installation
|
|
||||||
|
|
||||||
1. Copy the binary and config:
|
|
||||||
```bash
|
|
||||||
sudo useradd -r -s /usr/sbin/nologin roverd || true
|
|
||||||
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
|
|
||||||
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
|
|
||||||
```
|
|
||||||
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`; keep proxy-only path prefixes out of this URL so every rover keeps the same simple stream name.
|
|
||||||
|
|
||||||
2. Install the systemd unit:
|
|
||||||
```bash
|
|
||||||
sudo install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now roverd.service
|
|
||||||
```
|
|
||||||
|
|
||||||
`roverd` requires access to `/dev/ttyAMA0` and `/dev/gpiochip*`; keeping it under its own user ensures the rest of the system stays isolated—just make sure the account belongs to the `dialout` and `gpio` groups so it can reach the UART and libgpiod.
|
|
||||||
The video publisher needs read access to the camera devices (`/dev/media*`, `/dev/video*`), so the install script adds the `roverd` service account to the `video` group; if you created the user manually, make sure it belongs to `video`.
|
|
||||||
**BRC note:** configure `brc.gpioPin` (and `brc.gpioChip` if you’re not using `gpiochip0`) and ensure the `roverd` user has permission to toggle that line—no root privileges are required anymore.
|
|
||||||
If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl <action> <media.service>` (the installer wires `video-publisher.service` to run as `roverd`, so no sudo tweaks are required unless you rename it).
|
|
||||||
|
|
||||||
## Video publisher details
|
|
||||||
|
|
||||||
The `video-publisher.service` unit runs `/usr/local/bin/video-publisher`, piping `rpicam-vid`/`libcamera-vid` straight into the stock FFmpeg package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rpicam-vid (or libcamera-vid) --inline --timeout 0 --width=WIDTH --height=HEIGHT \
|
|
||||||
--framerate=FPS --bitrate=BITRATE --codec h264 --profile baseline --output - \
|
|
||||||
| ffmpeg -hide_banner -loglevel warning -fflags nobuffer \
|
|
||||||
-f h264 -i pipe:0 -c:v copy -an -flush_packets 1 -f mpegts $PUBLISH_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
`/var/lib/roverd/video.env` carries all tunables (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites the file whenever you restart it or hit the “Restart Camera” button so the publisher always inherits the correct rover ID + bitrate knobs. Because we now use the distro FFmpeg build with SRT enabled, the installer is much faster—no custom compile steps.
|
|
||||||
Use `sudo systemctl status video-publisher` to watch logs; the unit auto-restarts whenever the connection drops or FFmpeg exits with an error.
|
|
||||||
|
|
||||||
## Server + UI
|
|
||||||
|
|
||||||
From the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
This launches the HTTP server (serving the barebones UI) and the rover WebSocket endpoint at `ws://<server>:8080/rover`. The UI expects `roverd` instances to send `hello` frames so it can populate the rover list. Use the mode buttons to emit Start/Safe/Full/Passive/Dock commands (they send the raw OI opcode bytes), tap the sensor toggle to request Group 100 streaming, and use WASD for drive testing; the UI emits Drive Direct commands ~8 times per second, so the rover sees them immediately.
|
|
||||||
+74
-12
@@ -1,23 +1,85 @@
|
|||||||
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
||||||
options snd_rpi_googlevoicehat_soundcard index=0
|
options snd_rpi_googlevoicehat_soundcard index=0
|
||||||
|
|
||||||
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
|
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||||
pcm.softvol {
|
pcm.dmixer {
|
||||||
type softvol
|
type dmix
|
||||||
slave.pcm "plughw:0,0"
|
ipc_key 1024
|
||||||
control {
|
ipc_perm 0666
|
||||||
name "SoftMaster"
|
slave {
|
||||||
card 0
|
pcm "hw:0,0"
|
||||||
|
format S16_LE
|
||||||
|
rate 16000
|
||||||
|
channels 1
|
||||||
|
period_time 0
|
||||||
|
period_size 1024
|
||||||
|
buffer_size 4096
|
||||||
}
|
}
|
||||||
min_dB -51.0
|
|
||||||
max_dB 0.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Defaults: playback through softvol, capture raw on the HAT
|
# TTS volume control (used by default playback path).
|
||||||
|
pcm.tts_softvol {
|
||||||
|
type softvol
|
||||||
|
slave.pcm "dmixer"
|
||||||
|
control {
|
||||||
|
name "TTSMaster"
|
||||||
|
card 0
|
||||||
|
}
|
||||||
|
min_dB -60.0
|
||||||
|
max_dB 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Horn volume control.
|
||||||
|
pcm.horn_softvol {
|
||||||
|
type softvol
|
||||||
|
slave.pcm "dmixer"
|
||||||
|
control {
|
||||||
|
name "HornMaster"
|
||||||
|
card 0
|
||||||
|
}
|
||||||
|
min_dB -60.0
|
||||||
|
max_dB 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forwarded audio volume control.
|
||||||
|
pcm.forward_softvol {
|
||||||
|
type softvol
|
||||||
|
slave.pcm "dmixer"
|
||||||
|
control {
|
||||||
|
name "ForwardMaster"
|
||||||
|
card 0
|
||||||
|
}
|
||||||
|
min_dB -60.0
|
||||||
|
max_dB 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-source playback PCMs.
|
||||||
|
pcm.tts {
|
||||||
|
type plug
|
||||||
|
slave.pcm "tts_softvol"
|
||||||
|
}
|
||||||
|
|
||||||
|
pcm.horn {
|
||||||
|
type plug
|
||||||
|
slave.pcm "horn_softvol"
|
||||||
|
}
|
||||||
|
|
||||||
|
pcm.forward {
|
||||||
|
type plug
|
||||||
|
slave.pcm "forward_softvol"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capture alias used by rover config defaults.
|
||||||
|
pcm.rovermic {
|
||||||
|
type plug
|
||||||
|
slave.pcm "hw:0,0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defaults: TTS direct playback + raw capture on the HAT.
|
||||||
pcm.!default {
|
pcm.!default {
|
||||||
type asym
|
type asym
|
||||||
playback.pcm "softvol"
|
playback.pcm "tts"
|
||||||
capture.pcm "hw:0,0"
|
capture.pcm "rovermic"
|
||||||
}
|
}
|
||||||
|
|
||||||
ctl.!default {
|
ctl.!default {
|
||||||
|
|||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
set +H
|
||||||
|
|
||||||
|
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
|
||||||
|
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
|
||||||
|
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
|
||||||
|
AUDIO_NORMALIZE_ENABLE="${AUDIO_NORMALIZE_ENABLE:-1}"
|
||||||
|
AUDIO_NORMALIZE_FILTER="${AUDIO_NORMALIZE_FILTER:-dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled}"
|
||||||
|
|
||||||
|
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||||
|
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||||
|
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||||
|
else
|
||||||
|
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v aplay >/dev/null 2>&1; then
|
||||||
|
APLAY_BIN_PATH="$(command -v aplay)"
|
||||||
|
else
|
||||||
|
echo "aplay not found; install it via apt install alsa-utils." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LAST_FFMPEG_STATUS="unknown"
|
||||||
|
LAST_APLAY_STATUS="unknown"
|
||||||
|
|
||||||
|
run_pipeline() {
|
||||||
|
set +e
|
||||||
|
local -a ffmpeg_args=(
|
||||||
|
-hide_banner
|
||||||
|
-loglevel warning
|
||||||
|
-fflags nobuffer
|
||||||
|
-flags low_delay
|
||||||
|
-analyzeduration 200k
|
||||||
|
-probesize 32k
|
||||||
|
-i "${AUDIO_FORWARD_URL}"
|
||||||
|
-vn
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${AUDIO_NORMALIZE_ENABLE}" -ne 0 ]]; then
|
||||||
|
ffmpeg_args+=(-af "${AUDIO_NORMALIZE_FILTER}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
ffmpeg_args+=(
|
||||||
|
-ac 1
|
||||||
|
-ar 16000
|
||||||
|
-f s16le
|
||||||
|
pipe:1
|
||||||
|
)
|
||||||
|
|
||||||
|
"${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}" \
|
||||||
|
| "${APLAY_BIN_PATH}" \
|
||||||
|
-q \
|
||||||
|
-D "${PLAYBACK_DEVICE}" \
|
||||||
|
-t raw \
|
||||||
|
-f S16_LE \
|
||||||
|
-r 16000 \
|
||||||
|
-c 1
|
||||||
|
local rc=$?
|
||||||
|
local -a statuses=("${PIPESTATUS[@]}")
|
||||||
|
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
|
||||||
|
LAST_APLAY_STATUS="${statuses[1]:-unknown}"
|
||||||
|
set -e
|
||||||
|
return "${rc}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if run_pipeline; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Audio forward listener exited ffmpeg=${LAST_FFMPEG_STATUS:-unknown} aplay=${LAST_APLAY_STATUS:-unknown}, restarting in 2s..." >&2
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
@@ -71,10 +71,14 @@ VIDEO_WIDTH="640"
|
|||||||
VIDEO_HEIGHT="480"
|
VIDEO_HEIGHT="480"
|
||||||
VIDEO_FPS="30"
|
VIDEO_FPS="30"
|
||||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||||
|
VIDEO_INVERT="${VIDEO_INVERT:-1}"
|
||||||
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
||||||
|
|
||||||
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
# Flip the camera 180deg by default; allow upright camera mounts via VIDEO_INVERT=0.
|
||||||
FLIP_ARGS=(--rotation 180)
|
FLIP_ARGS=()
|
||||||
|
if [[ "${VIDEO_INVERT}" -ne 0 ]]; then
|
||||||
|
FLIP_ARGS=(--rotation 180)
|
||||||
|
fi
|
||||||
|
|
||||||
MODE_ARGS=()
|
MODE_ARGS=()
|
||||||
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
||||||
|
|||||||
+12
-4
@@ -20,8 +20,8 @@ Options:
|
|||||||
The script must run from the repository root and as root (sudo). It will:
|
The script must run from the repository root and as root (sudo). It will:
|
||||||
* create system users/groups if needed
|
* create system users/groups if needed
|
||||||
* install /usr/local/bin/roverd and /etc/roverd.yaml
|
* install /usr/local/bin/roverd and /etc/roverd.yaml
|
||||||
* install /usr/local/bin/video-publisher and its systemd unit
|
* install /usr/local/bin/video/audio helpers and systemd units
|
||||||
* enable roverd.service and video-publisher.service
|
* enable roverd.service and media publisher/listener services
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,14 +210,20 @@ log "Installed video-publisher systemd unit"
|
|||||||
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
|
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
|
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
|
||||||
log "Installed audio-only publisher helper + systemd unit"
|
log "Installed audio-only publisher helper + systemd unit"
|
||||||
|
# Install audio-forward listener assets
|
||||||
|
install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/bin/audio-forward-listener
|
||||||
|
install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
|
||||||
|
log "Installed audio-forward listener helper + systemd unit"
|
||||||
install -d -o roverd -g roverd /var/lib/roverd
|
install -d -o roverd -g roverd /var/lib/roverd
|
||||||
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=10&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
|
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
|
AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
VIDEO_BITRATE=2000000
|
VIDEO_BITRATE=2000000
|
||||||
AUDIO_ENABLE=0
|
AUDIO_ENABLE=0
|
||||||
AUDIO_DEVICE=hw:0,0
|
AUDIO_DEVICE=hw:0,0
|
||||||
|
AUDIO_PLAYBACK_DEVICE=forward
|
||||||
AUDIO_RATE=48000
|
AUDIO_RATE=48000
|
||||||
AUDIO_CHANNELS=2
|
AUDIO_CHANNELS=2
|
||||||
ENV
|
ENV
|
||||||
@@ -244,13 +250,15 @@ 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
|
systemctl enable audio-only-publisher.service
|
||||||
|
systemctl enable audio-forward-listener.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
|
||||||
systemctl restart audio-only-publisher.service
|
systemctl restart audio-only-publisher.service
|
||||||
log "Restarted roverd + video/audio publishers"
|
systemctl restart audio-forward-listener.service
|
||||||
|
log "Restarted roverd + media publishers/listener"
|
||||||
else
|
else
|
||||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher"
|
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher audio-forward-listener"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Install complete"
|
log "Install complete"
|
||||||
|
|||||||
+2
-1
@@ -11,9 +11,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
|
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
|
||||||
|
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/hornverifier ./cmd/hornverifier
|
||||||
|
|
||||||
dummy:
|
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 $(BIN_DIR)/servoverifier
|
rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier $(BIN_DIR)/hornverifier
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AudioLevels struct {
|
||||||
|
HornGain float64
|
||||||
|
TTSGain float64
|
||||||
|
ForwardGain float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampAudioGain(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 4 {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAudioLevels(v AudioLevels) AudioLevels {
|
||||||
|
v.HornGain = clampAudioGain(v.HornGain)
|
||||||
|
v.TTSGain = clampAudioGain(v.TTSGain)
|
||||||
|
v.ForwardGain = clampAudioGain(v.ForwardGain)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) getAudioLevels() AudioLevels {
|
||||||
|
c.audioMu.RLock()
|
||||||
|
defer c.audioMu.RUnlock()
|
||||||
|
return c.audioLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) setAudioLevels(next AudioLevels) {
|
||||||
|
normalized := normalizeAudioLevels(next)
|
||||||
|
c.audioMu.Lock()
|
||||||
|
c.audioLevels = normalized
|
||||||
|
c.audioMu.Unlock()
|
||||||
|
c.applyAudioLevelsToMixer(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
|
||||||
|
if payload == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
levels := c.getAudioLevels()
|
||||||
|
if payload.HornGain != nil {
|
||||||
|
levels.HornGain = clampAudioGain(*payload.HornGain)
|
||||||
|
}
|
||||||
|
if payload.TTSGain != nil {
|
||||||
|
levels.TTSGain = clampAudioGain(*payload.TTSGain)
|
||||||
|
}
|
||||||
|
if payload.ForwardGain != nil {
|
||||||
|
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
|
||||||
|
}
|
||||||
|
c.setAudioLevels(levels)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) applyAudioLevelsToMixer(levels AudioLevels) {
|
||||||
|
c.applyMixerGain("HornMaster", levels.HornGain)
|
||||||
|
c.applyMixerGain("TTSMaster", levels.TTSGain)
|
||||||
|
c.applyMixerGain("ForwardMaster", levels.ForwardGain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) applyMixerGain(control string, gain float64) {
|
||||||
|
normalized := clampAudioGain(gain)
|
||||||
|
if normalized <= 0 {
|
||||||
|
if err := c.trySetMixerControl(control, "0%"); err != nil {
|
||||||
|
c.log.Printf("audio-levels: amixer mute %s failed: %v", control, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert linear gain to dB, matching softvol max_dB=12.0 in /etc/asound.conf.
|
||||||
|
db := 20.0 * math.Log10(normalized)
|
||||||
|
if db > 12.0 {
|
||||||
|
db = 12.0
|
||||||
|
}
|
||||||
|
if db < -60.0 {
|
||||||
|
db = -60.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// amixer treats a leading "-" value as an option; set via percent to avoid getopt ambiguity.
|
||||||
|
percent := int(math.Round((db + 60.0) / 72.0 * 100.0))
|
||||||
|
if percent < 0 {
|
||||||
|
percent = 0
|
||||||
|
}
|
||||||
|
if percent > 100 {
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
percentArg := fmt.Sprintf("%d%%", percent)
|
||||||
|
if err := c.trySetMixerControl(control, percentArg); err != nil {
|
||||||
|
c.log.Printf("audio-levels: amixer set %s=%s failed: %v", control, percentArg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) trySetMixerControl(control, value string) error {
|
||||||
|
// Prefer the active ALSA default route; fall back to card index for compatibility.
|
||||||
|
candidates := [][]string{
|
||||||
|
{"-q", "-D", "default", "sset", control, value},
|
||||||
|
{"-q", "-c", "0", "sset", control, value},
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, args := range candidates {
|
||||||
|
out, err := exec.Command("amixer", args...).CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("%w (%s)", err, string(out))
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
autoChargeTimeout = 10 * time.Second
|
autoChargeTimeout = 5 * time.Second
|
||||||
autoChargeCooldown = 0 * time.Minute
|
autoChargeCooldown = 0 * time.Minute
|
||||||
sourceHomeBase = 1 << 1
|
sourceHomeBase = 1 << 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
rpio "github.com/stianeikeland/go-rpio/v4"
|
||||||
)
|
)
|
||||||
@@ -17,9 +18,17 @@ type CameraServo struct {
|
|||||||
pin rpio.Pin
|
pin rpio.Pin
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
currentAngle float64
|
currentAngle float64
|
||||||
|
desiredAngle float64
|
||||||
|
lastMove time.Time
|
||||||
|
moving bool
|
||||||
|
stopCh chan struct{}
|
||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxServoDegPerSec = 60.0
|
||||||
|
const servoStepInterval = 20 * time.Millisecond
|
||||||
|
const servoAngleEpsilon = 0.01
|
||||||
|
|
||||||
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
|
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
return nil, fmt.Errorf("camera servo disabled")
|
return nil, fmt.Errorf("camera servo disabled")
|
||||||
@@ -37,6 +46,7 @@ func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, er
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
pin: pin,
|
pin: pin,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
|
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
|
||||||
rpio.Close()
|
rpio.Close()
|
||||||
@@ -54,6 +64,10 @@ func (s *CameraServo) Close() {
|
|||||||
}
|
}
|
||||||
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
|
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
|
||||||
rpio.Close()
|
rpio.Close()
|
||||||
|
if s.stopCh != nil {
|
||||||
|
close(s.stopCh)
|
||||||
|
s.stopCh = nil
|
||||||
|
}
|
||||||
s.closed = true
|
s.closed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +82,13 @@ func (s *CameraServo) setAngleLocked(angle float64) error {
|
|||||||
return fmt.Errorf("servo closed")
|
return fmt.Errorf("servo closed")
|
||||||
}
|
}
|
||||||
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
|
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
|
||||||
s.applyPulseLocked(s.angleToPulse(clamped))
|
s.desiredAngle = clamped
|
||||||
s.currentAngle = clamped
|
limited := s.rateLimitAngleLocked(clamped)
|
||||||
|
s.applyPulseLocked(s.angleToPulse(limited))
|
||||||
|
s.currentAngle = limited
|
||||||
|
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
||||||
|
s.startMoveLoopLocked()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +114,15 @@ func (s *CameraServo) SetPulseWidth(micros int) error {
|
|||||||
if micros <= 0 {
|
if micros <= 0 {
|
||||||
return fmt.Errorf("pulse width must be > 0")
|
return fmt.Errorf("pulse width must be > 0")
|
||||||
}
|
}
|
||||||
s.applyPulseLocked(micros)
|
clampedPulse := clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
||||||
s.currentAngle = s.pulseToAngle(micros)
|
targetAngle := s.pulseToAngle(clampedPulse)
|
||||||
|
s.desiredAngle = targetAngle
|
||||||
|
limited := s.rateLimitAngleLocked(targetAngle)
|
||||||
|
s.applyPulseLocked(s.angleToPulse(limited))
|
||||||
|
s.currentAngle = limited
|
||||||
|
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
||||||
|
s.startMoveLoopLocked()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +137,68 @@ func (s *CameraServo) applyPulseLocked(micros int) {
|
|||||||
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
|
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CameraServo) startMoveLoopLocked() {
|
||||||
|
if s.moving || s.stopCh == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.moving = true
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(servoStepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.moving = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if math.Abs(s.currentAngle-s.desiredAngle) <= servoAngleEpsilon {
|
||||||
|
s.moving = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limited := s.rateLimitAngleLocked(s.desiredAngle)
|
||||||
|
s.applyPulseLocked(s.angleToPulse(limited))
|
||||||
|
s.currentAngle = limited
|
||||||
|
s.mu.Unlock()
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CameraServo) rateLimitAngleLocked(target float64) float64 {
|
||||||
|
now := time.Now()
|
||||||
|
if s.lastMove.IsZero() {
|
||||||
|
s.lastMove = now
|
||||||
|
}
|
||||||
|
elapsed := now.Sub(s.lastMove).Seconds()
|
||||||
|
if elapsed <= 0 {
|
||||||
|
s.lastMove = now
|
||||||
|
return s.currentAngle
|
||||||
|
}
|
||||||
|
maxElapsed := servoStepInterval.Seconds()
|
||||||
|
if elapsed > maxElapsed {
|
||||||
|
elapsed = maxElapsed
|
||||||
|
}
|
||||||
|
maxDelta := maxServoDegPerSec * elapsed
|
||||||
|
delta := target - s.currentAngle
|
||||||
|
if math.Abs(delta) <= maxDelta {
|
||||||
|
s.lastMove = now
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
if delta > 0 {
|
||||||
|
target = s.currentAngle + maxDelta
|
||||||
|
} else {
|
||||||
|
target = s.currentAngle - maxDelta
|
||||||
|
}
|
||||||
|
s.lastMove = now
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
func (s *CameraServo) angleToPulse(angle float64) int {
|
func (s *CameraServo) angleToPulse(angle float64) int {
|
||||||
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
|
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
|
||||||
if totalRange == 0 {
|
if totalRange == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const twoPi = 2 * math.Pi
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
device = flag.String("device", "", "ALSA device (empty = default)")
|
||||||
|
rate = flag.Int("rate", 48000, "Sample rate in Hz")
|
||||||
|
channels = flag.Int("channels", 1, "Number of audio channels")
|
||||||
|
duration = flag.Duration("duration", 2*time.Second, "Total horn duration")
|
||||||
|
freqsRaw = flag.String("freqs", "440,550,660", "Comma-separated frequencies in Hz")
|
||||||
|
volume = flag.Float64("volume", 0.25, "Output volume 0.0-1.0")
|
||||||
|
attack = flag.Duration("attack", 20*time.Millisecond, "Attack time")
|
||||||
|
release = flag.Duration("release", 60*time.Millisecond, "Release time")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *rate <= 0 {
|
||||||
|
log.Fatalf("rate must be > 0 (got %d)", *rate)
|
||||||
|
}
|
||||||
|
if *channels <= 0 {
|
||||||
|
log.Fatalf("channels must be > 0 (got %d)", *channels)
|
||||||
|
}
|
||||||
|
if *duration <= 0 {
|
||||||
|
log.Fatalf("duration must be > 0 (got %s)", *duration)
|
||||||
|
}
|
||||||
|
if *volume <= 0 || *volume > 1.0 {
|
||||||
|
log.Fatalf("volume must be within (0,1] (got %.3f)", *volume)
|
||||||
|
}
|
||||||
|
if *attack < 0 || *release < 0 {
|
||||||
|
log.Fatalf("attack/release must be >= 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
freqs, err := parseFreqs(*freqsRaw)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parse freqs: %v", err)
|
||||||
|
}
|
||||||
|
if len(freqs) == 0 {
|
||||||
|
log.Fatal("no frequencies provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if *attack+*release > *duration {
|
||||||
|
log.Fatalf("attack+release must be <= duration (%s + %s > %s)", *attack, *release, *duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", *channels), "-r", fmt.Sprintf("%d", *rate), "-t", "raw"}
|
||||||
|
if *device != "" {
|
||||||
|
args = append(args, "-D", *device)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("aplay", args...)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("aplay stdin: %v", err)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Fatalf("start aplay: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writer := bufio.NewWriterSize(stdin, 32*1024)
|
||||||
|
if err := synthChord(writer, freqs, *rate, *channels, *duration, *volume, *attack, *release); err != nil {
|
||||||
|
_ = stdin.Close()
|
||||||
|
_ = cmd.Wait()
|
||||||
|
log.Fatalf("synth: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Flush(); err != nil {
|
||||||
|
_ = stdin.Close()
|
||||||
|
_ = cmd.Wait()
|
||||||
|
log.Fatalf("flush: %v", err)
|
||||||
|
}
|
||||||
|
if err := stdin.Close(); err != nil {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
log.Fatalf("close stdin: %v", err)
|
||||||
|
}
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
log.Fatalf("aplay failed: %v", err)
|
||||||
|
}
|
||||||
|
log.Print("Horn verification complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFreqs(raw string) ([]float64, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(trimmed, ",")
|
||||||
|
freqs := make([]float64, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseFloat(part, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid freq %q", part)
|
||||||
|
}
|
||||||
|
if value <= 0 {
|
||||||
|
return nil, fmt.Errorf("freq must be > 0 (got %.3f)", value)
|
||||||
|
}
|
||||||
|
freqs = append(freqs, value)
|
||||||
|
}
|
||||||
|
return freqs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func synthChord(writer *bufio.Writer, freqs []float64, rate, channels int, duration time.Duration, volume float64, attack, release time.Duration) error {
|
||||||
|
totalFrames := int(float64(rate) * duration.Seconds())
|
||||||
|
if totalFrames <= 0 {
|
||||||
|
return fmt.Errorf("duration too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
phase := make([]float64, len(freqs))
|
||||||
|
increment := make([]float64, len(freqs))
|
||||||
|
for i, f := range freqs {
|
||||||
|
increment[i] = twoPi * f / float64(rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
attackFrames := int(float64(rate) * attack.Seconds())
|
||||||
|
releaseFrames := int(float64(rate) * release.Seconds())
|
||||||
|
steadyFrames := totalFrames - attackFrames - releaseFrames
|
||||||
|
|
||||||
|
framesPerChunk := 512
|
||||||
|
buf := make([]byte, framesPerChunk*channels*2)
|
||||||
|
sampleIndex := 0
|
||||||
|
scale := volume / float64(len(freqs))
|
||||||
|
|
||||||
|
for framesLeft := totalFrames; framesLeft > 0; {
|
||||||
|
framesNow := framesPerChunk
|
||||||
|
if framesLeft < framesNow {
|
||||||
|
framesNow = framesLeft
|
||||||
|
}
|
||||||
|
for i := 0; i < framesNow; i++ {
|
||||||
|
env := envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames)
|
||||||
|
sample := 0.0
|
||||||
|
for j := range freqs {
|
||||||
|
sample += sawFromPhase(phase[j])
|
||||||
|
phase[j] += increment[j]
|
||||||
|
if phase[j] > twoPi {
|
||||||
|
phase[j] -= twoPi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample *= scale * env
|
||||||
|
if sample > 1.0 {
|
||||||
|
sample = 1.0
|
||||||
|
} else if sample < -1.0 {
|
||||||
|
sample = -1.0
|
||||||
|
}
|
||||||
|
intSample := int16(sample * math.MaxInt16)
|
||||||
|
offset := i * channels * 2
|
||||||
|
for ch := 0; ch < channels; ch++ {
|
||||||
|
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
||||||
|
}
|
||||||
|
sampleIndex++
|
||||||
|
}
|
||||||
|
if _, err := writer.Write(buf[:framesNow*channels*2]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
framesLeft -= framesNow
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames int) float64 {
|
||||||
|
if attackFrames > 0 && sampleIndex < attackFrames {
|
||||||
|
return float64(sampleIndex) / float64(attackFrames)
|
||||||
|
}
|
||||||
|
if releaseFrames > 0 && sampleIndex >= attackFrames+steadyFrames {
|
||||||
|
relIndex := sampleIndex - (attackFrames + steadyFrames)
|
||||||
|
return float64(releaseFrames-relIndex) / float64(releaseFrames)
|
||||||
|
}
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sawFromPhase(phase float64) float64 {
|
||||||
|
return 2.0*(phase/twoPi) - 1.0
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ package roverd
|
|||||||
type helloMessage struct {
|
type helloMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
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"`
|
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||||
Audio AudioConfig `json:"audio"`
|
Audio AudioConfig `json:"audio"`
|
||||||
|
Horn HornConfig `json:"horn"`
|
||||||
NightVision NightVisionConfig `json:"nightVision"`
|
NightVision NightVisionConfig `json:"nightVision"`
|
||||||
|
Private PrivateConfig `json:"private"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sensorMessage struct {
|
type sensorMessage struct {
|
||||||
@@ -27,8 +30,11 @@ type inboundMessage struct {
|
|||||||
Media *mediaCommand `json:"media,omitempty"`
|
Media *mediaCommand `json:"media,omitempty"`
|
||||||
Servo *servoPayload `json:"servo,omitempty"`
|
Servo *servoPayload `json:"servo,omitempty"`
|
||||||
TTS *ttsPayload `json:"tts,omitempty"`
|
TTS *ttsPayload `json:"tts,omitempty"`
|
||||||
|
Horn *hornPayload `json:"horn,omitempty"`
|
||||||
|
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
|
||||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||||
Song *songPayload `json:"song,omitempty"`
|
Song *songPayload `json:"song,omitempty"`
|
||||||
|
Reboot *rebootPayload `json:"reboot,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
type driveDirectPayload struct {
|
||||||
@@ -64,6 +70,18 @@ type ttsPayload struct {
|
|||||||
Speak bool `json:"speak,omitempty"`
|
Speak bool `json:"speak,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type hornPayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Waveform string `json:"waveform,omitempty"`
|
||||||
|
Freqs []float64 `json:"freqs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type audioLevelsPayload struct {
|
||||||
|
HornGain *float64 `json:"hornGain,omitempty"`
|
||||||
|
TTSGain *float64 `json:"ttsGain,omitempty"`
|
||||||
|
ForwardGain *float64 `json:"forwardGain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type nightVisionPayload struct {
|
type nightVisionPayload struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
}
|
}
|
||||||
@@ -79,6 +97,10 @@ type songNote struct {
|
|||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rebootPayload struct {
|
||||||
|
DelayMs int `json:"delayMs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ackMessage struct {
|
type ackMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
+148
-4
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -56,6 +58,7 @@ type BatteryConfig struct {
|
|||||||
type AudioConfig struct {
|
type AudioConfig struct {
|
||||||
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
||||||
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
|
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
|
||||||
|
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"`
|
||||||
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
|
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
|
||||||
Channels int `yaml:"channels" json:"channels,omitempty"`
|
Channels int `yaml:"channels" json:"channels,omitempty"`
|
||||||
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
|
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
|
||||||
@@ -65,19 +68,29 @@ type AudioConfig struct {
|
|||||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HornConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
Volume float64 `yaml:"volume" json:"-"`
|
||||||
|
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||||
|
Channels int `yaml:"channels" json:"-"`
|
||||||
|
Device string `yaml:"device" json:"-"`
|
||||||
|
SineGain float64 `yaml:"sineGain" json:"-"`
|
||||||
|
SawGain float64 `yaml:"sawGain" json:"-"`
|
||||||
|
MaxDuration Duration `yaml:"maxDuration" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type MediaConfig struct {
|
type MediaConfig struct {
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||||
|
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
|
||||||
PublishPort int `yaml:"publishPort" json:"-"`
|
PublishPort int `yaml:"publishPort" json:"-"`
|
||||||
|
CameraInverted bool `yaml:"cameraInverted" json:"-"`
|
||||||
Manage bool `yaml:"manage"`
|
Manage bool `yaml:"manage"`
|
||||||
ManageAudio bool `yaml:"manageAudio"`
|
ManageAudio bool `yaml:"manageAudio"`
|
||||||
Service string `yaml:"service"`
|
Service string `yaml:"service"`
|
||||||
AudioService string `yaml:"audioService"`
|
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:"-"`
|
|
||||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
|
||||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
|
||||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +116,33 @@ type NightVisionConfig struct {
|
|||||||
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoSideBrushConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Speed int `yaml:"speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrivateConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
Safety PrivateSafetyConfig `yaml:"safety" json:"safety"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrivateSafetyConfig struct {
|
||||||
|
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
|
||||||
|
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
|
||||||
|
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
|
||||||
|
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
|
||||||
|
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
|
||||||
|
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
|
||||||
|
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
|
||||||
|
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
||||||
|
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
||||||
|
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
||||||
|
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
|
Color string `yaml:"color" json:"color,omitempty"`
|
||||||
ServerURL string `yaml:"serverUrl"`
|
ServerURL string `yaml:"serverUrl"`
|
||||||
Serial SerialConfig `yaml:"serial"`
|
Serial SerialConfig `yaml:"serial"`
|
||||||
BRC BRCConfig `yaml:"brc"`
|
BRC BRCConfig `yaml:"brc"`
|
||||||
@@ -113,7 +151,10 @@ type Config struct {
|
|||||||
Media MediaConfig `yaml:"media"`
|
Media MediaConfig `yaml:"media"`
|
||||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||||
Audio AudioConfig `yaml:"audio"`
|
Audio AudioConfig `yaml:"audio"`
|
||||||
|
Horn HornConfig `yaml:"horn"`
|
||||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||||
|
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
|
||||||
|
Private PrivateConfig `yaml:"private" json:"private"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
@@ -135,6 +176,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
},
|
},
|
||||||
Media: MediaConfig{
|
Media: MediaConfig{
|
||||||
PublishPort: 9000,
|
PublishPort: 9000,
|
||||||
|
CameraInverted: true,
|
||||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||||
VideoBitrate: 2000000,
|
VideoBitrate: 2000000,
|
||||||
},
|
},
|
||||||
@@ -152,6 +194,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
Audio: AudioConfig{
|
Audio: AudioConfig{
|
||||||
CaptureEnabled: false,
|
CaptureEnabled: false,
|
||||||
CaptureDevice: "rovermic",
|
CaptureDevice: "rovermic",
|
||||||
|
PlaybackDevice: "forward",
|
||||||
SampleRate: 48000,
|
SampleRate: 48000,
|
||||||
Channels: 2,
|
Channels: 2,
|
||||||
Bitrate: 24000,
|
Bitrate: 24000,
|
||||||
@@ -160,12 +203,41 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
DefaultVoice: "rms",
|
DefaultVoice: "rms",
|
||||||
DefaultPitch: 50,
|
DefaultPitch: 50,
|
||||||
},
|
},
|
||||||
|
Horn: HornConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Volume: 0.25,
|
||||||
|
SampleRate: 48000,
|
||||||
|
Channels: 1,
|
||||||
|
SineGain: 1.0,
|
||||||
|
SawGain: 0.7,
|
||||||
|
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
|
||||||
|
},
|
||||||
NightVision: NightVisionConfig{
|
NightVision: NightVisionConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
GPIOPin: 22,
|
GPIOPin: 22,
|
||||||
GPIOChip: "gpiochip0",
|
GPIOChip: "gpiochip0",
|
||||||
InitialOn: true,
|
InitialOn: true,
|
||||||
},
|
},
|
||||||
|
AutoSideBrush: AutoSideBrushConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Speed: 20,
|
||||||
|
},
|
||||||
|
Private: PrivateConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Safety: PrivateSafetyConfig{
|
||||||
|
SpeedLimitEnabled: false,
|
||||||
|
SpeedLimitMaxWheelMMs: 250,
|
||||||
|
HardOvercurrentEnabled: false,
|
||||||
|
OvercurrentStopMs: 300,
|
||||||
|
HardBumpEnabled: false,
|
||||||
|
BumpBackoffSpeed: 250,
|
||||||
|
BumpBackoffMs: 350,
|
||||||
|
CliffEnabled: false,
|
||||||
|
CliffBackoffSpeed: 250,
|
||||||
|
CliffBackoffMs: 500,
|
||||||
|
TriggerCooldownMs: 800,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -173,6 +245,11 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if cfg.Name == "" {
|
if cfg.Name == "" {
|
||||||
return nil, errors.New("missing name")
|
return nil, errors.New("missing name")
|
||||||
}
|
}
|
||||||
|
normalizedColor, err := normalizeHexColor(cfg.Color)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Color = normalizedColor
|
||||||
if cfg.ServerURL == "" {
|
if cfg.ServerURL == "" {
|
||||||
return nil, errors.New("missing serverUrl")
|
return nil, errors.New("missing serverUrl")
|
||||||
}
|
}
|
||||||
@@ -214,6 +291,13 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.Media.AudioPublishURL = derived
|
cfg.Media.AudioPublishURL = derived
|
||||||
}
|
}
|
||||||
|
if cfg.Media.AudioForwardURL == "" {
|
||||||
|
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Media.AudioForwardURL = derived
|
||||||
|
}
|
||||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||||
}
|
}
|
||||||
@@ -221,6 +305,8 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("nightVision: %w", err)
|
return nil, fmt.Errorf("nightVision: %w", err)
|
||||||
}
|
}
|
||||||
validateAudioConfig(&cfg.Audio)
|
validateAudioConfig(&cfg.Audio)
|
||||||
|
validateHornConfig(&cfg.Horn)
|
||||||
|
validateAutoSideBrushConfig(&cfg.AutoSideBrush)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +357,9 @@ func validateAudioConfig(cfg *AudioConfig) {
|
|||||||
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
|
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
|
||||||
cfg.CaptureDevice = "hw:0,0"
|
cfg.CaptureDevice = "hw:0,0"
|
||||||
}
|
}
|
||||||
|
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
|
||||||
|
cfg.PlaybackDevice = "forward"
|
||||||
|
}
|
||||||
if cfg.SampleRate <= 0 {
|
if cfg.SampleRate <= 0 {
|
||||||
cfg.SampleRate = 48000
|
cfg.SampleRate = 48000
|
||||||
}
|
}
|
||||||
@@ -291,6 +380,30 @@ func validateAudioConfig(cfg *AudioConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateHornConfig(cfg *HornConfig) {
|
||||||
|
if cfg.Volume <= 0 {
|
||||||
|
cfg.Volume = 0.25
|
||||||
|
}
|
||||||
|
if cfg.Volume > 1 {
|
||||||
|
cfg.Volume = 1
|
||||||
|
}
|
||||||
|
if cfg.SampleRate <= 0 {
|
||||||
|
cfg.SampleRate = 48000
|
||||||
|
}
|
||||||
|
if cfg.Channels <= 0 {
|
||||||
|
cfg.Channels = 1
|
||||||
|
}
|
||||||
|
if cfg.SineGain <= 0 {
|
||||||
|
cfg.SineGain = 1.0
|
||||||
|
}
|
||||||
|
if cfg.SawGain <= 0 {
|
||||||
|
cfg.SawGain = 0.7
|
||||||
|
}
|
||||||
|
if cfg.MaxDuration.Duration <= 0 {
|
||||||
|
cfg.MaxDuration = Duration{Duration: 1200 * time.Millisecond}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
return nil
|
return nil
|
||||||
@@ -304,10 +417,28 @@ func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
|
||||||
|
if cfg.Speed == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Speed = clampInt(cfg.Speed, -127, 127)
|
||||||
|
}
|
||||||
|
|
||||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||||
|
return deriveSRTURL(serverURL, streamName, port, "publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
||||||
|
return deriveSRTURL(serverURL, streamName, port, "request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
|
||||||
if streamName == "" {
|
if streamName == "" {
|
||||||
return "", errors.New("missing stream name for publishUrl")
|
return "", errors.New("missing stream name for publishUrl")
|
||||||
}
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = "publish"
|
||||||
|
}
|
||||||
parsed, err := url.Parse(serverURL)
|
parsed, err := url.Parse(serverURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -320,5 +451,18 @@ func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
|||||||
port = 9000
|
port = 9000
|
||||||
}
|
}
|
||||||
escaped := url.PathEscape(streamName)
|
escaped := url.PathEscape(streamName)
|
||||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped), nil
|
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||||
|
|
||||||
|
func normalizeHexColor(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !hexColorRe.MatchString(trimmed) {
|
||||||
|
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
|
||||||
|
}
|
||||||
|
return strings.ToUpper(trimmed), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hornAttack = 20 * time.Millisecond
|
||||||
|
hornRelease = 60 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type HornSynth struct {
|
||||||
|
cfg HornConfig
|
||||||
|
log *log.Logger
|
||||||
|
gain float64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
stop chan struct{}
|
||||||
|
active bool
|
||||||
|
proc *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
|
||||||
|
return &HornSynth{
|
||||||
|
cfg: cfg,
|
||||||
|
log: logger,
|
||||||
|
gain: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HornSynth) SetGlobalGain(gain float64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.gain = clampAudioGain(gain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
|
||||||
|
if payload == nil {
|
||||||
|
return fmt.Errorf("horn payload required")
|
||||||
|
}
|
||||||
|
action := strings.ToLower(strings.TrimSpace(payload.Action))
|
||||||
|
switch action {
|
||||||
|
case "start", "on", "honk":
|
||||||
|
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
|
||||||
|
if waveform != "sine" && waveform != "saw" {
|
||||||
|
waveform = "saw"
|
||||||
|
}
|
||||||
|
freqs := sanitizeHornFreqs(payload.Freqs)
|
||||||
|
if len(freqs) == 0 {
|
||||||
|
h.Stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return h.Start(waveform, freqs)
|
||||||
|
case "stop", "off":
|
||||||
|
h.Stop()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported horn action: %s", payload.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HornSynth) Start(waveform string, freqs []float64) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.active {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
h.stop = stop
|
||||||
|
h.active = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
go h.run(waveform, freqs, stop)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HornSynth) Stop() {
|
||||||
|
h.mu.Lock()
|
||||||
|
if !h.active {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop := h.stop
|
||||||
|
proc := h.proc
|
||||||
|
h.stop = nil
|
||||||
|
h.proc = nil
|
||||||
|
h.active = false
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
if proc != nil && proc.Process != nil {
|
||||||
|
_ = proc.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}) {
|
||||||
|
rate := h.cfg.SampleRate
|
||||||
|
if rate <= 0 {
|
||||||
|
rate = 48000
|
||||||
|
}
|
||||||
|
channels := h.cfg.Channels
|
||||||
|
if channels <= 0 {
|
||||||
|
channels = 1
|
||||||
|
}
|
||||||
|
volume := h.cfg.Volume
|
||||||
|
if volume <= 0 {
|
||||||
|
volume = 0.25
|
||||||
|
}
|
||||||
|
if volume > 1 {
|
||||||
|
volume = 1
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
gain := h.gain
|
||||||
|
h.mu.Unlock()
|
||||||
|
volume *= gain
|
||||||
|
|
||||||
|
device := strings.TrimSpace(h.cfg.Device)
|
||||||
|
if device == "" {
|
||||||
|
device = "horn"
|
||||||
|
}
|
||||||
|
args := []string{"-q", "-D", device, "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
|
||||||
|
cmd := exec.Command("aplay", args...)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
h.log.Printf("horn: aplay stdin failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
h.log.Printf("horn: aplay start failed: %v", err)
|
||||||
|
_ = stdin.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.active {
|
||||||
|
h.proc = cmd
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
writer := bufio.NewWriterSize(stdin, 32*1024)
|
||||||
|
maxFrames := 0
|
||||||
|
if h.cfg.MaxDuration.Duration > 0 {
|
||||||
|
maxFrames = int(float64(rate) * h.cfg.MaxDuration.Duration.Seconds())
|
||||||
|
}
|
||||||
|
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, maxFrames, stop); err != nil {
|
||||||
|
h.log.Printf("horn: synth failed: %v", err)
|
||||||
|
}
|
||||||
|
_ = writer.Flush()
|
||||||
|
_ = stdin.Close()
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
h.log.Printf("horn: aplay exit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.proc == cmd {
|
||||||
|
h.proc = nil
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, maxFrames int, stop <-chan struct{}) error {
|
||||||
|
phase := make([]float64, len(freqs))
|
||||||
|
increment := make([]float64, len(freqs))
|
||||||
|
for i, f := range freqs {
|
||||||
|
increment[i] = 2 * math.Pi * f / float64(rate)
|
||||||
|
}
|
||||||
|
attackFrames := int(float64(rate) * hornAttack.Seconds())
|
||||||
|
releaseFrames := int(float64(rate) * hornRelease.Seconds())
|
||||||
|
framesPerChunk := 512
|
||||||
|
buf := make([]byte, framesPerChunk*channels*2)
|
||||||
|
scale := volume / float64(len(freqs))
|
||||||
|
if waveform == "sine" {
|
||||||
|
scale *= h.cfg.SineGain
|
||||||
|
} else {
|
||||||
|
scale *= h.cfg.SawGain
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRequested := false
|
||||||
|
releaseStart := -1
|
||||||
|
sampleIndex := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
if !stopRequested {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
stopRequested = true
|
||||||
|
releaseStart = sampleIndex
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < framesPerChunk; i++ {
|
||||||
|
if maxFrames > 0 && sampleIndex >= maxFrames && !stopRequested {
|
||||||
|
stopRequested = true
|
||||||
|
releaseStart = sampleIndex
|
||||||
|
}
|
||||||
|
env := 1.0
|
||||||
|
if attackFrames > 0 && sampleIndex < attackFrames {
|
||||||
|
env = float64(sampleIndex) / float64(attackFrames)
|
||||||
|
} else if stopRequested && releaseFrames > 0 {
|
||||||
|
relIndex := sampleIndex - releaseStart
|
||||||
|
if relIndex >= releaseFrames {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
|
||||||
|
} else if stopRequested {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sample := 0.0
|
||||||
|
for j := range freqs {
|
||||||
|
switch waveform {
|
||||||
|
case "sine":
|
||||||
|
sample += math.Sin(phase[j])
|
||||||
|
default:
|
||||||
|
sample += sawFromPhase(phase[j])
|
||||||
|
}
|
||||||
|
phase[j] += increment[j]
|
||||||
|
if phase[j] > 2*math.Pi {
|
||||||
|
phase[j] -= 2 * math.Pi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample *= scale * env
|
||||||
|
if sample > 1.0 {
|
||||||
|
sample = 1.0
|
||||||
|
} else if sample < -1.0 {
|
||||||
|
sample = -1.0
|
||||||
|
}
|
||||||
|
intSample := int16(sample * math.MaxInt16)
|
||||||
|
offset := i * channels * 2
|
||||||
|
for ch := 0; ch < channels; ch++ {
|
||||||
|
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
||||||
|
}
|
||||||
|
sampleIndex++
|
||||||
|
}
|
||||||
|
if _, err := writer.Write(buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeHornFreqs(freqs []float64) []float64 {
|
||||||
|
if len(freqs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]float64, 0, 4)
|
||||||
|
for _, f := range freqs {
|
||||||
|
if len(out) >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if f <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sawFromPhase(phase float64) float64 {
|
||||||
|
return 2.0*(phase/(2*math.Pi)) - 1.0
|
||||||
|
}
|
||||||
+10
-10
@@ -16,8 +16,8 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||||
return fmt.Errorf("audio publishUrl missing")
|
return fmt.Errorf("audio publishUrl missing")
|
||||||
}
|
}
|
||||||
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
if media.VideoBitrate <= 0 {
|
||||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
return fmt.Errorf("invalid media 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
|
||||||
@@ -27,16 +27,11 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||||
}
|
}
|
||||||
if media.VideoWidth > 0 {
|
if media.AudioForwardURL != "" {
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
||||||
}
|
|
||||||
if media.VideoHeight > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
|
||||||
}
|
|
||||||
if media.VideoFPS > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||||
|
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
|
||||||
audioDevice := audio.CaptureDevice
|
audioDevice := audio.CaptureDevice
|
||||||
if audioDevice == "" || audioDevice == "rovermic" {
|
if audioDevice == "" || audioDevice == "rovermic" {
|
||||||
audioDevice = "hw:0,0"
|
audioDevice = "hw:0,0"
|
||||||
@@ -49,6 +44,11 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
|
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
|
||||||
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
|
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
|
||||||
|
playbackDevice := audio.PlaybackDevice
|
||||||
|
if playbackDevice == "" {
|
||||||
|
playbackDevice = "forward"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
|
||||||
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
|
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
|
||||||
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
|
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 {
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ func (n *NightVisionLight) HandleAction(action string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NightVisionLight) NightVisionOn() bool {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
return !n.on
|
||||||
|
}
|
||||||
|
|
||||||
func (n *NightVisionLight) setLocked(on bool) error {
|
func (n *NightVisionLight) setLocked(on bool) error {
|
||||||
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
|
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ func (n *NightVisionLight) Close() {}
|
|||||||
func (n *NightVisionLight) HandleAction(action string) error {
|
func (n *NightVisionLight) HandleAction(action string) error {
|
||||||
return fmt.Errorf("night vision not supported in dummy build")
|
return fmt.Errorf("night vision not supported in dummy build")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NightVisionLight) NightVisionOn() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
# Sample configuration for roverd
|
# Sample configuration for roverd
|
||||||
name: roomba-alpha
|
name: roomba-alpha
|
||||||
|
color: "#4DB6AC"
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyAMA0
|
device: /dev/ttyAMA0
|
||||||
@@ -16,7 +17,10 @@ battery:
|
|||||||
maxWheelSpeed: 350
|
maxWheelSpeed: 350
|
||||||
media:
|
media:
|
||||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
|
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
publishPort: 9000
|
publishPort: 9000
|
||||||
|
# Default assumes camera is mounted upside down; set false for upright mounts.
|
||||||
|
cameraInverted: true
|
||||||
videoBitrate: 2000000
|
videoBitrate: 2000000
|
||||||
manage: true
|
manage: true
|
||||||
service: video-publisher.service
|
service: video-publisher.service
|
||||||
@@ -38,6 +42,7 @@ cameraServo:
|
|||||||
audio:
|
audio:
|
||||||
captureEnabled: false
|
captureEnabled: false
|
||||||
captureDevice: hw:0,0
|
captureDevice: hw:0,0
|
||||||
|
playbackDevice: forward
|
||||||
sampleRate: 48000
|
sampleRate: 48000
|
||||||
channels: 2
|
channels: 2
|
||||||
bitrate: 24000
|
bitrate: 24000
|
||||||
@@ -45,8 +50,33 @@ audio:
|
|||||||
defaultEngine: flite
|
defaultEngine: flite
|
||||||
defaultVoice: rms
|
defaultVoice: rms
|
||||||
defaultPitch: 50
|
defaultPitch: 50
|
||||||
|
horn:
|
||||||
|
enabled: false
|
||||||
|
volume: 0.25
|
||||||
|
sampleRate: 48000
|
||||||
|
channels: 1
|
||||||
|
sineGain: 1.0
|
||||||
|
sawGain: 0.7
|
||||||
|
maxDuration: 1.2s
|
||||||
nightVision:
|
nightVision:
|
||||||
enabled: true
|
enabled: true
|
||||||
gpioPin: 22
|
gpioPin: 22
|
||||||
gpioChip: gpiochip0
|
gpioChip: gpiochip0
|
||||||
initialOn: true
|
initialOn: true
|
||||||
|
autoSideBrush:
|
||||||
|
enabled: true
|
||||||
|
speed: 20
|
||||||
|
private:
|
||||||
|
enabled: false
|
||||||
|
safety:
|
||||||
|
speedLimitEnabled: false
|
||||||
|
speedLimitMaxWheelSpeed: 250
|
||||||
|
hardOvercurrentEnabled: false
|
||||||
|
overcurrentStopMs: 300
|
||||||
|
hardBumpEnabled: false
|
||||||
|
bumpBackoffSpeed: 250
|
||||||
|
bumpBackoffMs: 350
|
||||||
|
cliffEnabled: false
|
||||||
|
cliffBackoffSpeed: 250
|
||||||
|
cliffBackoffMs: 500
|
||||||
|
triggerCooldownMs: 800
|
||||||
|
|||||||
@@ -31,3 +31,20 @@ cameraServo:
|
|||||||
homeAngle: 0
|
homeAngle: 0
|
||||||
nudgeDegrees: 2
|
nudgeDegrees: 2
|
||||||
allowRawPulse: false
|
allowRawPulse: false
|
||||||
|
autoSideBrush:
|
||||||
|
enabled: true
|
||||||
|
speed: 20
|
||||||
|
private:
|
||||||
|
enabled: false
|
||||||
|
safety:
|
||||||
|
speedLimitEnabled: false
|
||||||
|
speedLimitMaxWheelSpeed: 250
|
||||||
|
hardOvercurrentEnabled: false
|
||||||
|
overcurrentStopMs: 300
|
||||||
|
hardBumpEnabled: false
|
||||||
|
bumpBackoffSpeed: 250
|
||||||
|
bumpBackoffMs: 350
|
||||||
|
cliffEnabled: false
|
||||||
|
cliffBackoffSpeed: 250
|
||||||
|
cliffBackoffMs: 500
|
||||||
|
triggerCooldownMs: 800
|
||||||
|
|||||||
+187
-5
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,15 +20,22 @@ type WSClient struct {
|
|||||||
events chan RoverEvent
|
events chan RoverEvent
|
||||||
media *MediaSupervisor
|
media *MediaSupervisor
|
||||||
servo *CameraServo
|
servo *CameraServo
|
||||||
|
horn *HornSynth
|
||||||
nightVision *NightVisionLight
|
nightVision *NightVisionLight
|
||||||
log *log.Logger
|
log *log.Logger
|
||||||
recoverMu sync.Mutex
|
recoverMu sync.Mutex
|
||||||
recovering bool
|
recovering bool
|
||||||
ttsQueue chan *ttsPayload
|
ttsQueue chan *ttsPayload
|
||||||
|
lastAux motorPWMPayload
|
||||||
|
autoSideOn bool
|
||||||
connMu sync.Mutex
|
connMu sync.Mutex
|
||||||
connected bool
|
connected bool
|
||||||
disconnectT *time.Timer
|
disconnectT *time.Timer
|
||||||
|
rebootT *time.Timer
|
||||||
seekIssued bool
|
seekIssued bool
|
||||||
|
rebootIssued bool
|
||||||
|
audioLevels AudioLevels
|
||||||
|
audioMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||||
@@ -35,21 +43,35 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
|||||||
if cfg.Audio.TTSEnabled {
|
if cfg.Audio.TTSEnabled {
|
||||||
ttsQueue = make(chan *ttsPayload, 2)
|
ttsQueue = make(chan *ttsPayload, 2)
|
||||||
}
|
}
|
||||||
return &WSClient{
|
var horn *HornSynth
|
||||||
|
if cfg.Horn.Enabled {
|
||||||
|
horn = NewHornSynth(cfg.Horn, logger)
|
||||||
|
}
|
||||||
|
client := &WSClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
sensorFrames: frames,
|
sensorFrames: frames,
|
||||||
events: events,
|
events: events,
|
||||||
media: media,
|
media: media,
|
||||||
servo: servo,
|
servo: servo,
|
||||||
|
horn: horn,
|
||||||
nightVision: nightVision,
|
nightVision: nightVision,
|
||||||
log: logger,
|
log: logger,
|
||||||
ttsQueue: ttsQueue,
|
ttsQueue: ttsQueue,
|
||||||
|
audioLevels: AudioLevels{
|
||||||
|
HornGain: 1.0,
|
||||||
|
TTSGain: 1.0,
|
||||||
|
ForwardGain: 1.0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
client.applyAudioLevelsToMixer(client.audioLevels)
|
||||||
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) Run(ctx context.Context) error {
|
func (c *WSClient) Run(ctx context.Context) error {
|
||||||
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
|
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
|
||||||
|
conn, _, err := websocket.Dial(dialCtx, c.cfg.ServerURL, nil)
|
||||||
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.markDisconnected()
|
c.markDisconnected()
|
||||||
return err
|
return err
|
||||||
@@ -65,11 +87,16 @@ func (c *WSClient) Run(ctx context.Context) error {
|
|||||||
c.log.Printf("sensor stream init failed: %v", err)
|
c.log.Printf("sensor stream init failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 2)
|
||||||
c.startTTSWorker(ctx)
|
c.startTTSWorker(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
errCh <- c.readLoop(ctx, conn)
|
errCh <- c.readLoop(ctx, conn)
|
||||||
}()
|
}()
|
||||||
|
go func() {
|
||||||
|
if err := c.keepalive(ctx, conn); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
go c.forwardSensors(ctx, conn)
|
go c.forwardSensors(ctx, conn)
|
||||||
go c.forwardEvents(ctx, conn)
|
go c.forwardEvents(ctx, conn)
|
||||||
|
|
||||||
@@ -86,12 +113,15 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
|||||||
msg := helloMessage{
|
msg := helloMessage{
|
||||||
Type: "hello",
|
Type: "hello",
|
||||||
Name: c.cfg.Name,
|
Name: c.cfg.Name,
|
||||||
|
Color: c.cfg.Color,
|
||||||
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,
|
CameraServo: c.cfg.CameraServo,
|
||||||
Audio: c.cfg.Audio,
|
Audio: c.cfg.Audio,
|
||||||
|
Horn: c.cfg.Horn,
|
||||||
NightVision: c.cfg.NightVision,
|
NightVision: c.cfg.NightVision,
|
||||||
|
Private: c.cfg.Private,
|
||||||
}
|
}
|
||||||
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
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)
|
||||||
@@ -135,11 +165,17 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
case msg.DriveDirect != nil:
|
case msg.DriveDirect != nil:
|
||||||
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
||||||
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
||||||
return c.adapter.DriveDirect(left, right)
|
if err := c.adapter.DriveDirect(left, right); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.applyAutoSideBrush(left, right)
|
||||||
|
return nil
|
||||||
case msg.MotorPWM != nil:
|
case msg.MotorPWM != nil:
|
||||||
main := clamp(msg.MotorPWM.Main, -127, 127)
|
main := clamp(msg.MotorPWM.Main, -127, 127)
|
||||||
side := clamp(msg.MotorPWM.Side, -127, 127)
|
side := clamp(msg.MotorPWM.Side, -127, 127)
|
||||||
vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
|
vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
|
||||||
|
c.lastAux = motorPWMPayload{Main: main, Side: side, Vacuum: vac}
|
||||||
|
c.autoSideOn = false
|
||||||
return c.adapter.MotorPWM(main, side, vac)
|
return c.adapter.MotorPWM(main, side, vac)
|
||||||
case msg.SensorStream != nil:
|
case msg.SensorStream != nil:
|
||||||
if msg.SensorStream.Enable {
|
if msg.SensorStream.Enable {
|
||||||
@@ -170,22 +206,121 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
return c.handleServoCommand(msg.Servo)
|
return c.handleServoCommand(msg.Servo)
|
||||||
case msg.TTS != nil:
|
case msg.TTS != nil:
|
||||||
return c.enqueueTTS(msg.TTS)
|
return c.enqueueTTS(msg.TTS)
|
||||||
|
case msg.Horn != nil:
|
||||||
|
if c.horn == nil {
|
||||||
|
return fmt.Errorf("horn disabled")
|
||||||
|
}
|
||||||
|
return c.horn.HandlePayload(msg.Horn)
|
||||||
|
case msg.AudioLevels != nil:
|
||||||
|
return c.handleAudioLevels(msg.AudioLevels)
|
||||||
case msg.NightVision != nil:
|
case msg.NightVision != nil:
|
||||||
if c.nightVision == nil {
|
if c.nightVision == nil {
|
||||||
return fmt.Errorf("night vision disabled")
|
return fmt.Errorf("night vision disabled")
|
||||||
}
|
}
|
||||||
return c.nightVision.HandleAction(msg.NightVision.Action)
|
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.emitEvent("nightVision.state", map[string]any{
|
||||||
|
"nightVisionOn": c.nightVision.NightVisionOn(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
case msg.Song != nil:
|
case msg.Song != nil:
|
||||||
slot := 0
|
slot := 0
|
||||||
if msg.Song.Slot != nil {
|
if msg.Song.Slot != nil {
|
||||||
slot = clampInt(*msg.Song.Slot, 0, 4)
|
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||||
}
|
}
|
||||||
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
||||||
|
case msg.Reboot != nil || msg.Type == "reboot":
|
||||||
|
return c.handleRebootCommand(msg.Reboot)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
|
||||||
|
if err := c.adapter.DriveDirect(0, 0); err != nil {
|
||||||
|
return fmt.Errorf("stop drive before reboot: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
|
||||||
|
return fmt.Errorf("stop aux motors before reboot: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.adapter.StartOI(); err != nil {
|
||||||
|
return fmt.Errorf("enter passive mode before reboot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delay := 300 * time.Millisecond
|
||||||
|
if payload != nil && payload.DelayMs > 0 {
|
||||||
|
delay = time.Duration(clampInt(payload.DelayMs, 50, 5000)) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.rebootIssued {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
return fmt.Errorf("reboot already pending")
|
||||||
|
}
|
||||||
|
c.rebootIssued = true
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
c.emitEvent("system.rebooting", map[string]any{
|
||||||
|
"source": "remoteCommand",
|
||||||
|
"delayMs": delay.Milliseconds(),
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(delay)
|
||||||
|
c.log.Printf("rebooting pi after remote reboot command")
|
||||||
|
cmd := exec.Command("systemctl", "reboot")
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
c.log.Printf("reboot command failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) applyAutoSideBrush(left, right int) {
|
||||||
|
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
|
||||||
|
if c.autoSideOn {
|
||||||
|
c.autoSideOn = false
|
||||||
|
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
||||||
|
c.log.Printf("auto side brush stop failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moving := left != 0 || right != 0
|
||||||
|
if !moving {
|
||||||
|
if c.autoSideOn {
|
||||||
|
c.autoSideOn = false
|
||||||
|
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
||||||
|
c.log.Printf("auto side brush stop failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.lastAux.Side != 0 {
|
||||||
|
c.autoSideOn = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSpeed := clampInt(c.cfg.AutoSideBrush.Speed, -127, 127)
|
||||||
|
if autoSpeed == 0 {
|
||||||
|
c.autoSideOn = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.autoSideOn {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.adapter.MotorPWM(c.lastAux.Main, autoSpeed, c.lastAux.Vacuum); err != nil {
|
||||||
|
c.log.Printf("auto side brush start failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.autoSideOn = true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
|
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
|
||||||
if c.ttsQueue == nil {
|
if c.ttsQueue == nil {
|
||||||
return fmt.Errorf("tts disabled")
|
return fmt.Errorf("tts disabled")
|
||||||
@@ -352,15 +487,43 @@ func (c *WSClient) ensureSensorStream() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disconnectSeekDelay = time.Minute
|
const disconnectSeekDelay = time.Minute
|
||||||
|
const disconnectRebootDelay = 6 * time.Minute
|
||||||
|
const dialTimeout = 10 * time.Second
|
||||||
|
const pingInterval = 15 * time.Second
|
||||||
|
const pingTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
|
||||||
|
ticker := time.NewTicker(pingInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||||
|
err := conn.Ping(pingCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WSClient) markConnected() {
|
func (c *WSClient) markConnected() {
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
c.connected = true
|
c.connected = true
|
||||||
c.seekIssued = false
|
c.seekIssued = false
|
||||||
|
c.rebootIssued = false
|
||||||
if c.disconnectT != nil {
|
if c.disconnectT != nil {
|
||||||
c.disconnectT.Stop()
|
c.disconnectT.Stop()
|
||||||
c.disconnectT = nil
|
c.disconnectT = nil
|
||||||
}
|
}
|
||||||
|
if c.rebootT != nil {
|
||||||
|
c.rebootT.Stop()
|
||||||
|
c.rebootT = nil
|
||||||
|
}
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +535,9 @@ func (c *WSClient) markDisconnected() {
|
|||||||
if c.disconnectT == nil {
|
if c.disconnectT == nil {
|
||||||
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
|
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
|
||||||
}
|
}
|
||||||
|
if c.rebootT == nil {
|
||||||
|
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
|
||||||
|
}
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +557,22 @@ func (c *WSClient) handleDisconnectTimeout() {
|
|||||||
c.log.Printf("seek dock issued after websocket disconnect")
|
c.log.Printf("seek dock issued after websocket disconnect")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) handleRebootTimeout() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.connected || c.rebootIssued {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.rebootIssued = true
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
c.log.Printf("rebooting pi after prolonged websocket disconnect")
|
||||||
|
cmd := exec.Command("systemctl", "reboot")
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
c.log.Printf("reboot command failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
|
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
|
||||||
c.recoverMu.Lock()
|
c.recoverMu.Lock()
|
||||||
if c.recovering {
|
if c.recovering {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Rover Audio Forward Listener (SRT -> ALSA)
|
||||||
|
After=network-online.target roverd.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=roverd
|
||||||
|
Group=roverd
|
||||||
|
EnvironmentFile=/var/lib/roverd/video.env
|
||||||
|
ExecStart=/usr/local/bin/audio-forward-listener
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=5
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# main idea:
|
||||||
|
- stream audio from server to rovers
|
||||||
|
- users can either stream their mic from their browser
|
||||||
|
- users can also play audio files on the rover through the browser
|
||||||
|
- this is a VIP feature for verified users only
|
||||||
|
- gate in UI and in the server
|
||||||
|
|
||||||
|
## specifics
|
||||||
|
- only the current driver can play audio through a rover
|
||||||
|
- admins can enable / disable audio
|
||||||
|
- lockdown admins can adjust the volume for all rovers
|
||||||
|
- rovers are always listening for an audio stream from the server
|
||||||
|
- no transcoding allowed on-rover due to resources
|
||||||
|
- rovers are always local and cant be accessed from outside, no security is needed for audio streaming
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# access control modes:
|
|
||||||
- open
|
|
||||||
- open to the public
|
|
||||||
- users are randomly assigned a rover to drive, with priority on the rover with the least amount of drivers
|
|
||||||
- if there are multiple people on one rover, those people will all be controlling that rover at the same time
|
|
||||||
- turns
|
|
||||||
- open to the public
|
|
||||||
- users are randomly assigned to a rover same as open mode
|
|
||||||
- if there are multiple people on one rover, they will each have one minute at a time to drive.
|
|
||||||
- the turn queue loops
|
|
||||||
- the rover will stop moving and stop all aux. motors if the turn switches to a different person
|
|
||||||
- admin
|
|
||||||
- admin authentication is required to access the driver page at all
|
|
||||||
- lockdown
|
|
||||||
- ONLY lockdown admins can access the driver page
|
|
||||||
- the future spectator page is DISABLED (not even a way to log into it)
|
|
||||||
|
|
||||||
# roles in the access control system:
|
|
||||||
- user
|
|
||||||
- default, for normal people who visit the site
|
|
||||||
- admin
|
|
||||||
- authentication needed
|
|
||||||
- can drive and view any rover when not on lockdown, no matter who is controlling it
|
|
||||||
- can switch modes (including switching to lockdown mode)
|
|
||||||
- lockdown admin
|
|
||||||
- authentication needed
|
|
||||||
- only works if said admin has lockdown: enabled in config
|
|
||||||
- can ALWAYS access and control EVERYTHING that there is to do on the site
|
|
||||||
|
|
||||||
# other things to keep in mind:
|
|
||||||
- in the future, there will be a Discord bot for community alerts and a few admin controls
|
|
||||||
- wherever the admin list is configured, there has to be a spot for their discord ID
|
|
||||||
- admin's discord IDs are linked to their admin name server-side
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# 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,587 @@
|
|||||||
|
# Dead Code Audit (Static)
|
||||||
|
|
||||||
|
Generated: 2026-05-10T21:29:09-04:00
|
||||||
|
|
||||||
|
## A) High-confidence never used files
|
||||||
|
- server/src/services/overseerControlService/tools/chatSay.js (not registered in tools/index.js, no refs)
|
||||||
|
- server/prompts/commentary_system_backup_small.txt (no refs)
|
||||||
|
- server/prompts/commentary_system_backup_pre_lobotomy.txt (no refs)
|
||||||
|
- webui/src/App.css (not imported)
|
||||||
|
- webui/src/assets/react.svg (no refs)
|
||||||
|
- server/assets/test-audio.mp3 (no refs)
|
||||||
|
- webui/public/vite.svg (no refs from app/server)
|
||||||
|
- webui/dist/assets/index-APFDmmUd.js (build artifact, not runtime-wired)
|
||||||
|
- webui/dist/assets/index-DoNH_msu.css (build artifact, not runtime-wired)
|
||||||
|
- dist/dummy1.yml (no refs)
|
||||||
|
- dist/dummy2.yml (no refs)
|
||||||
|
- dist/dummy3.yml (no refs)
|
||||||
|
|
||||||
|
## B) Unused dependency candidates
|
||||||
|
- webui/package.json: react-joystick-component (no imports)
|
||||||
|
|
||||||
|
## C) Commented-out JSX/UI blocks (never rendered while commented)
|
||||||
|
webui/src/App.jsx:85: {/* <SessionSnapshot /> */}
|
||||||
|
webui/src/App.jsx:153: {/* {showTelemetry ? <TelemetryPanel /> : null} */}
|
||||||
|
webui/src/App.jsx:183: {/* <ControlSummary /> */}
|
||||||
|
webui/src/App.jsx:211: {/* <TelemetryPanel /> */}
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:82: {/* <button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.8rem]">
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:84: </button> */}
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:90: {/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:116: {/* <button type="button" onClick={onOpenHelp} className="button-dark px-1 py-0.25">
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:118: </button> */}
|
||||||
|
webui/src/components/DriveDockAction/index.jsx:261: {/* {!isMobile && expanded ? ( */}
|
||||||
|
webui/src/components/UserListPanel/index.jsx:263: {/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
|
||||||
|
webui/src/components/AuthPanel/index.jsx:48: {/* <div className="flex gap-0.5 text-sm">
|
||||||
|
webui/src/components/AuthPanel/index.jsx:55: </div> */}
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:85: {/* {reasonUpdatedAt ? (
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:89: ) : null} */}
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:102: {/* set max height of this box */}
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:108: {/* <p className="text-xs text-slate-500">
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:111: </p> */}
|
||||||
|
webui/src/components/RoomCameraPanel/index.jsx:103: {/* <header className="space-y-0.5">
|
||||||
|
webui/src/components/RoomCameraPanel/index.jsx:106: </header> */}
|
||||||
|
webui/src/components/DriverVideoPanel/index.jsx:145: {/* colored button to visit the spectator page */}
|
||||||
|
webui/src/components/TelemetryPanel/index.jsx:43: {/* <div className="text-sm text-slate-400">
|
||||||
|
webui/src/components/TelemetryPanel/index.jsx:49: </div> */}
|
||||||
|
|
||||||
|
## D) Unused exports reported by knip (server)
|
||||||
|
Unused exports (66)
|
||||||
|
CHARGING_STATE src/helpers/sensorDecoder.js:224:3
|
||||||
|
pickRandomReward src/rewards/index.js:44:3
|
||||||
|
MAX_REASON_LENGTH src/services/adminReasonService/index.js:97:3
|
||||||
|
setAudioLevels src/services/audioLevelsService/index.js:153:3
|
||||||
|
pushLevelsToRover src/services/audioLevelsService/index.js:154:3
|
||||||
|
isAdmin src/services/authService/index.js:80:3
|
||||||
|
isLockdownAdmin src/services/authService/index.js:81:3
|
||||||
|
authenticate src/services/authService/index.js:82:3
|
||||||
|
isDuplicate src/services/chatService/contentFilters.js:57:3
|
||||||
|
handleIncoming src/services/chatService/index.js:32:3
|
||||||
|
buildTypingPayload src/services/chatService/index.js:35:3
|
||||||
|
rateBuckets src/services/chatService/state.js:44:3
|
||||||
|
buildRoverStatusSnapshot src/services/discordBotService/batteryEmbeds.js:132:3
|
||||||
|
eventBus src/services/eventBus/index.js:56:3
|
||||||
|
subscribeAll src/services/eventBus/index.js:59:3
|
||||||
|
MAX_GOAL_LENGTH src/services/globalObjectiveService/index.js:107:3
|
||||||
|
refreshIdleState src/services/idleService/index.js:86:3
|
||||||
|
DEFAULT_FREQUENCY_MS src/services/llmCommentaryService/constants.js:31:3
|
||||||
|
MIN_FREQUENCY_MS src/services/llmCommentaryService/constants.js:32:3
|
||||||
|
normalizeCommentary src/services/llmCommentaryService/formatters.js:175:3
|
||||||
|
enforceLockdown src/services/lockdownGuard/index.js:29:3
|
||||||
|
disconnectForLockdown src/services/lockdownGuard/index.js:30:3
|
||||||
|
parseOverseerOutput src/services/overseerControlService/runtimeHelpers.js:114:3
|
||||||
|
TOOL_DEFINITIONS src/services/overseerControlService/tools/index.js:87:3
|
||||||
|
getToolById src/services/overseerControlService/tools/index.js:91:3
|
||||||
|
getIdForSignature src/services/overseerControlService/tools/index.js:92:3
|
||||||
|
DM_APPROVE_EMOJI src/services/privateRoverAccessRequestService/index.js:25:3
|
||||||
|
DM_DENY_EMOJI src/services/privateRoverAccessRequestService/index.js:26:3
|
||||||
|
createRequest src/services/privateRoverAccessRequestService/index.js:29:3
|
||||||
|
hasClosedPrivateAccessForSocket src/services/privateRoverAccessRequestService/index.js:30:3
|
||||||
|
FFMPEG_BIN src/services/replayEngineV2/sources.js:66:3
|
||||||
|
upsertRover src/services/roverManager/index.js:234:3
|
||||||
|
removeRover src/services/roverManager/index.js:235:3
|
||||||
|
setPrivateOpen src/services/roverManager/index.js:237:3
|
||||||
|
setPrivateSafety src/services/roverManager/index.js:238:3
|
||||||
|
getRoster src/services/roverManager/index.js:239:3
|
||||||
|
getRosterForSocket src/services/roverManager/index.js:240:3
|
||||||
|
broadcastRoster src/services/roverManager/index.js:241:3
|
||||||
|
setNightVisionState src/services/roverManager/index.js:242:3
|
||||||
|
handleSensorFrame src/services/roverManager/index.js:243:3
|
||||||
|
requestControl src/services/roverManager/index.js:244:3
|
||||||
|
releaseControl src/services/roverManager/index.js:245:3
|
||||||
|
removeSocket src/services/roverManager/index.js:246:3
|
||||||
|
isDriver src/services/roverManager/index.js:247:3
|
||||||
|
canDrive src/services/roverManager/index.js:248:3
|
||||||
|
enableSpectator src/services/roverManager/index.js:249:3
|
||||||
|
disableSpectator src/services/roverManager/index.js:250:3
|
||||||
|
getRoversForSocket src/services/roverManager/index.js:253:3
|
||||||
|
getPrimaryRoverForSocket src/services/roverManager/index.js:254:3
|
||||||
|
canSeeRover src/services/roverManager/index.js:255:3
|
||||||
|
canRequestControl src/services/roverManager/index.js:256:3
|
||||||
|
applyPrivateDriveSafety src/services/roverManager/index.js:257:3
|
||||||
|
canReplayRoverId src/services/roverManager/index.js:258:3
|
||||||
|
computeBatteryDisplayPercent src/services/roverManager/mathUtils.js:136:3
|
||||||
|
buildSession src/services/sessionService/index.js:340:3
|
||||||
|
syncSocket src/services/sessionService/index.js:341:3
|
||||||
|
syncAll src/services/sessionService/index.js:342:3
|
||||||
|
driverAdded src/services/turnService/index.js:284:3
|
||||||
|
driverRemoved src/services/turnService/index.js:285:3
|
||||||
|
cleanupRover src/services/turnService/index.js:286:3
|
||||||
|
canDrive src/services/turnService/index.js:287:3
|
||||||
|
createSession src/services/videoSessions/index.js:68:3
|
||||||
|
getSession src/services/videoSessions/index.js:69:3
|
||||||
|
revokeSession src/services/videoSessions/index.js:70:3
|
||||||
|
revokeBySocket src/services/videoSessions/index.js:71:3
|
||||||
|
revokeWhere src/services/videoSessions/index.js:72:3
|
||||||
|
|
||||||
|
## E) Unused exports reported by knip (webui)
|
||||||
|
Unused exports (18)
|
||||||
|
deriveDriveDockState function src/components/DriveDockAction/index.jsx:9:17
|
||||||
|
HelpContentView function src/components/HelpContentView/index.jsx:179:17
|
||||||
|
default function src/components/UserListPanel/index.jsx:50:25
|
||||||
|
normalizeDriveVector function src/controls/controlMath.js:22:17
|
||||||
|
useOvercurrentLimiter src/controls/index.js:6:10
|
||||||
|
cloneProfile function src/controls/inputs/gamepadBindings.js:14:17
|
||||||
|
getGamepadHubState function src/controls/inputs/gamepadHub.js:109:17
|
||||||
|
deriveCodeForKey function src/controls/keymapUtils.js:36:17
|
||||||
|
createKeyToken function src/controls/keymapUtils.js:47:17
|
||||||
|
createCodeToken function src/controls/keymapUtils.js:52:17
|
||||||
|
DEFAULT_OVERCURRENT_LIMITS src/controls/overcurrentLimiter.js:12:14
|
||||||
|
HELP_LAYOUTS src/help/content.js:3:14
|
||||||
|
HELP_CONTENT src/help/content.js:7:14
|
||||||
|
default function src/hooks/useFullscreenPrompt.js:169:16
|
||||||
|
normalizeRoverColor function src/lib/roverColor.js:5:17
|
||||||
|
roverSwatchStyle function src/lib/roverColor.js:35:17
|
||||||
|
useSettings src/settings/index.js:3:28
|
||||||
|
useSettings function src/settings/SettingsProvider.jsx:58:17
|
||||||
|
Duplicate exports (2)
|
||||||
|
HelpContentView|default src/components/HelpContentView/index.jsx
|
||||||
|
useFullscreenPrompt|default src/hooks/useFullscreenPrompt.js
|
||||||
|
|
||||||
|
## F) Server State-Machine Contradictions (Proof-Based)
|
||||||
|
|
||||||
|
### F1) `lockdown-admin` role branches are unreachable in current role producer graph
|
||||||
|
**Why unreachable:**
|
||||||
|
- All server role writes are done through `setRole(socket, role)` in auth flows.
|
||||||
|
- Role assignments are only `user`, `spectator`, `admin`, `lockdown`.
|
||||||
|
- No assignment path sets `lockdown-admin`.
|
||||||
|
|
||||||
|
**Role producers (source of truth):**
|
||||||
|
- `server/src/services/authService/index.js` (`initialRole` user/spectator, login role admin/lockdown, role:set only user/spectator)
|
||||||
|
- `server/src/services/roleService/index.js` (just stores whatever caller sets; no separate producer)
|
||||||
|
- Searched for any `setRole(..., 'lockdown-admin')` / `socket.data.role = 'lockdown-admin'`: none
|
||||||
|
|
||||||
|
**Dead branches/cases under this graph:**
|
||||||
|
- `server/src/services/adminLogService/index.js`
|
||||||
|
- `server/src/services/llmCommentaryService/runtimeHelpers.js`
|
||||||
|
- `server/src/services/overseerControlService/runtimeHelpers.js`
|
||||||
|
- `server/src/services/verificationService/identity.js`
|
||||||
|
- `server/src/services/discordBotService/integrations/helpers.js`
|
||||||
|
- `server/src/services/replayEngineV2/sidebarRenderer.js`
|
||||||
|
- `webui/src/components/ModeGateOverlay/index.jsx`
|
||||||
|
- `webui/src/components/UserListPanel/index.jsx`
|
||||||
|
- `webui/src/components/ChatMessageRow/index.jsx`
|
||||||
|
- `webui/src/components/RoverQueuesPanel/index.jsx`
|
||||||
|
- `webui/src/components/RawUserPilePanel/index.jsx`
|
||||||
|
- `webui/src/components/AdminPanel/AdminPanelContent.jsx`
|
||||||
|
- `webui/src/controls/overcurrentLimiter.js`
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F2) `clearLockdownTimer` currently has no possible effect
|
||||||
|
**Why unreachable/effectively dead:**
|
||||||
|
- `clearLockdownTimer(socket)` only clears `socket.data.lockdownTimer`.
|
||||||
|
- No code ever sets `socket.data.lockdownTimer` anywhere in repo.
|
||||||
|
- Therefore the condition is always false and this function is a no-op in current runtime.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `server/src/services/lockdownGuard/index.js` (only reads/clears `lockdownTimer`)
|
||||||
|
- global search for `lockdownTimer` assignments: none
|
||||||
|
- caller: `server/src/services/authService/index.js` (invokes `clearLockdownTimer` after login)
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F3) `PERIODIC_SYNC_MS` config path is dead (constant + import)
|
||||||
|
**Why unreachable/effectively dead:**
|
||||||
|
- `PERIODIC_SYNC_MS` is imported into session service but only used in a commented-out `setInterval` block.
|
||||||
|
- No runtime path consumes this value.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `server/src/services/sessionService/constants.js` exports `PERIODIC_SYNC_MS`
|
||||||
|
- `server/src/services/sessionService/index.js` imports it and references it only in commented block
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F4) Video request parser supports `room` request shape, but runtime contract rejects it
|
||||||
|
**Why semantically contradictory:**
|
||||||
|
- `videoSocketService.normalizeRequest()` accepts room request payloads (`roomCameraId` / `{type:'room'}`).
|
||||||
|
- The handler then always throws for `target.type === 'room'` with “Room cameras now use the snapshot feed”.
|
||||||
|
- So room-video request acceptance code is legacy compatibility surface with guaranteed failure.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `server/src/services/videoSocketService/index.js`
|
||||||
|
|
||||||
|
**Note:** This is reachable only if a client attempts room video via `video:request`; it is not a successful runtime feature path.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F5) `useVideoRequests` still contains room-source normalization path unused by current first-party call sites
|
||||||
|
**Why currently redundant:**
|
||||||
|
- `useVideoRequests` can normalize `roomCameraId` / `type:'room'`.
|
||||||
|
- Current call sites pass rover-only entries:
|
||||||
|
- `webui/src/components/DriverVideoPanel/index.jsx`
|
||||||
|
- `webui/src/spectate/SpectatorApp/SpectatorContent.jsx`
|
||||||
|
- `webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx`
|
||||||
|
- This aligns with room camera delivery moving to snapshot socket feed.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `webui/src/hooks/useVideoRequests.js`
|
||||||
|
- call-site inspection above
|
||||||
|
|
||||||
|
**Confidence:** Medium-High (internal app paths only; external/future caller may use room shape)
|
||||||
|
|
||||||
|
## G) Legacy Compatibility Surface (Single-Program Dead Ends)
|
||||||
|
|
||||||
|
### G1) Duplicate socket event listeners: only `session:*` names are used by this client
|
||||||
|
**Observation:** webui emits only `session:*` variants for control-role operations.
|
||||||
|
|
||||||
|
**WebUI emit calls:**
|
||||||
|
- `session:setRole`
|
||||||
|
- `session:requestControl`
|
||||||
|
- `session:releaseControl`
|
||||||
|
- `session:lockRover`
|
||||||
|
- `session:privateSafety:set`
|
||||||
|
- `session:subscribeAll`
|
||||||
|
(see `webui/src/context/SessionContext.jsx`)
|
||||||
|
|
||||||
|
**Server still listens to both old + namespaced aliases:**
|
||||||
|
- `requestControl` + `session:requestControl`
|
||||||
|
- `releaseControl` + `session:releaseControl`
|
||||||
|
- `lockRover` + `session:lockRover`
|
||||||
|
- `privateSafety:set` + `session:privateSafety:set`
|
||||||
|
- `subscribeAll` + `session:subscribeAll`
|
||||||
|
(see `server/src/services/roverManager/socketHandlers.js`)
|
||||||
|
|
||||||
|
- `role:set` + `session:setRole`
|
||||||
|
(see `server/src/services/authService/index.js`)
|
||||||
|
|
||||||
|
**Why dead under single-program assumption:**
|
||||||
|
- No first-party client emits old names; old listeners are compatibility-only.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### G2) Server emits protocol events with no first-party subscribers
|
||||||
|
**Server emits:**
|
||||||
|
- `rovers` (on connect and roster updates)
|
||||||
|
- `auth:role`
|
||||||
|
- `mode`
|
||||||
|
- `controlGranted`
|
||||||
|
- `lockdown`
|
||||||
|
|
||||||
|
**Evidence of emitters:**
|
||||||
|
- `server/src/services/roverManager/socketHandlers.js` (`rovers`, `controlGranted`)
|
||||||
|
- `server/src/services/roverManager/rosterLifecycle.js` (`rovers`)
|
||||||
|
- `server/src/services/authService/index.js` (`auth:role`)
|
||||||
|
- `server/src/services/modeManager/index.js` (`mode`)
|
||||||
|
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
|
||||||
|
|
||||||
|
**Client-side consumption check:**
|
||||||
|
- No `socket.on('rovers' | 'auth:role' | 'mode' | 'controlGranted' | 'lockdown')` anywhere in `webui/src`.
|
||||||
|
- Session-driven UI uses `session:sync` instead.
|
||||||
|
|
||||||
|
**Why dead under single-program assumption:**
|
||||||
|
- Event emissions exist for older/external clients only.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### G3) `video:request` still accepts room payload shapes that are hard-rejected
|
||||||
|
**Current behavior:**
|
||||||
|
- Request normalization accepts room forms (`roomCameraId`, `{type:'room', id}`)
|
||||||
|
- Handler immediately throws for room type: "Room cameras now use the snapshot feed"
|
||||||
|
(see `server/src/services/videoSocketService/index.js`)
|
||||||
|
|
||||||
|
**Why compatibility-only:**
|
||||||
|
- Room WHEP path retained in request parsing despite product contract migrating to snapshot feed.
|
||||||
|
- First-party room camera path uses `roomCamera:*` snapshot sockets.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### G4) Global-objective legacy filename fallback likely one-time migration shim
|
||||||
|
**Behavior:**
|
||||||
|
- Reads canonical `global-objective.json`, else attempts legacy `community-goal.json`.
|
||||||
|
(see `server/src/services/globalObjectiveService/index.js`)
|
||||||
|
|
||||||
|
**Why likely compatibility-only:**
|
||||||
|
- This fallback exists purely for pre-rename data compatibility.
|
||||||
|
- In a single coordinated deployment, once migrated, legacy read path is dead.
|
||||||
|
|
||||||
|
**Confidence:** Medium-High (depends on whether legacy file still exists in your deployed data dir)
|
||||||
|
|
||||||
|
### G5) Data directory legacy fallback path is compatibility shim
|
||||||
|
**Behavior:**
|
||||||
|
- `resolveDataPath`/`resolveDataDir` checks canonical `server/data` and legacy `server/src/data` style location.
|
||||||
|
(see `server/src/helpers/dataPaths.js`)
|
||||||
|
|
||||||
|
**Why likely compatibility-only:**
|
||||||
|
- Exists to preserve prior storage layout after refactor.
|
||||||
|
- If deployment has stabilized on canonical path or explicit `SERVER_DATA_DIR`, legacy branch never used.
|
||||||
|
|
||||||
|
**Confidence:** Medium-High (environment dependent)
|
||||||
|
|
||||||
|
### G6) Overseer output parser keeps legacy one-line fallback parser
|
||||||
|
**Behavior:**
|
||||||
|
- Attempts JSON parse first; on failure falls back to historical one-line parse protocol.
|
||||||
|
(see `server/src/services/overseerControlService/runtimeHelpers.js`)
|
||||||
|
|
||||||
|
**Why compatibility-only:**
|
||||||
|
- Current prompt/protocol can be constrained to structured JSON output.
|
||||||
|
- Fallback branch preserves old non-JSON output compatibility.
|
||||||
|
|
||||||
|
**Confidence:** Medium (depends on model output guarantees / prompt hardening)
|
||||||
|
|
||||||
|
## H) Additional Deep Sweep Findings (Repo-Wide)
|
||||||
|
|
||||||
|
### H1) Uncalled helper export: `eventBus.subscribeAll`
|
||||||
|
**Evidence:**
|
||||||
|
- Declared/exported in `server/src/services/eventBus/index.js`
|
||||||
|
- No call sites in `server/src` or `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** hard dead utility export in current codebase.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H2) Uncalled helper export: `overseerControl.runtimeHelpers.parseOverseerOutput`
|
||||||
|
**Evidence:**
|
||||||
|
- Declared/exported in `server/src/services/overseerControlService/runtimeHelpers.js`
|
||||||
|
- `overseerControl/index.js` imports only `{ isAdminRole, buildAdminState, buildFailureInfo }`
|
||||||
|
- No other call sites in repo
|
||||||
|
|
||||||
|
**Assessment:** dead parser path (including its legacy one-line fallback) in current wiring.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H3) Uncalled helper export: `overseerControl.tools.getIdForSignature`
|
||||||
|
**Evidence:**
|
||||||
|
- Declared/exported in `server/src/services/overseerControlService/tools/index.js`
|
||||||
|
- No call sites in repo
|
||||||
|
|
||||||
|
**Assessment:** dead compatibility/helper function.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H4) `socket.emit('rovers', ...)` channel appears fully orphaned
|
||||||
|
**Evidence:**
|
||||||
|
- Emitted by server in rover manager connect/roster paths:
|
||||||
|
- `server/src/services/roverManager/socketHandlers.js`
|
||||||
|
- `server/src/services/roverManager/rosterLifecycle.js`
|
||||||
|
- No `socket.on('rovers', ...)` consumer in `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** legacy protocol emission; superseded by `session:sync` usage.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H5) `auth:role` and `mode` push events appear orphaned for first-party UI
|
||||||
|
**Evidence:**
|
||||||
|
- Emitted by:
|
||||||
|
- `server/src/services/authService/index.js` (`auth:role`)
|
||||||
|
- `server/src/services/modeManager/index.js` (`mode`)
|
||||||
|
- No consumers in `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** compatibility emissions for non-current clients.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H6) `controlGranted` / `lockdown` push events appear orphaned for first-party UI
|
||||||
|
**Evidence:**
|
||||||
|
- Emitted by:
|
||||||
|
- `server/src/services/roverManager/socketHandlers.js` (`controlGranted`)
|
||||||
|
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
|
||||||
|
- No consumers in `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** compatibility/legacy push surface for old clients.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H7) Pi/roverd `dummy` paths are not dead by default (intentional build target)
|
||||||
|
**Evidence:**
|
||||||
|
- Build-tag split (`//go:build dummy` vs `!dummy`) in serial/sensor/nightvision/camera_servo/brc modules
|
||||||
|
- `Makefile` has explicit `dummy` target: `go build -tags dummy ...`
|
||||||
|
|
||||||
|
**Assessment:** keep; this is an intentional alternate runtime, not dead code.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H8) `roomcam-service` appears operationally standalone (not wired by installers)
|
||||||
|
**Evidence:**
|
||||||
|
- Present as service + script under `roomcam-service/`
|
||||||
|
- Not installed by `server/install_server.sh` or `pi/install_roverd.sh`
|
||||||
|
- Server room camera feature consumes configured URLs and does not require this local service specifically
|
||||||
|
|
||||||
|
**Assessment:** likely optional/ops artifact; remove only if you do not deploy it manually.
|
||||||
|
**Confidence:** Medium
|
||||||
|
|
||||||
|
### H9) `dist/dummy{1,2,3}.yml` still has no runtime references
|
||||||
|
**Evidence:**
|
||||||
|
- No code paths consume these files
|
||||||
|
- Existing references are only in the files themselves
|
||||||
|
|
||||||
|
**Assessment:** hard dead artifacts in repo runtime context.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H10) `buttonbox/src/config example.h` is a template, not runtime code
|
||||||
|
**Evidence:**
|
||||||
|
- Firmware includes `<config.h>`; template file is named `config example.h`
|
||||||
|
- Typical manual-copy onboarding artifact
|
||||||
|
|
||||||
|
**Assessment:** optional docs/template artifact; not dead logic, but not build-consumed unless manually copied.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
## I) Final Confirmed-Only List (Static Proof)
|
||||||
|
|
||||||
|
These are the items I can confirm from source/wiring alone with high confidence.
|
||||||
|
|
||||||
|
### I1) Definitely uncalled functions/exports
|
||||||
|
- `server/src/services/eventBus/index.js` → `subscribeAll`
|
||||||
|
- `server/src/services/overseerControlService/runtimeHelpers.js` → `parseOverseerOutput`
|
||||||
|
- `server/src/services/overseerControlService/tools/index.js` → `getIdForSignature`
|
||||||
|
|
||||||
|
Proof: repo-wide usage search returns definition only (no call sites).
|
||||||
|
|
||||||
|
### I2) Definitely orphan server push events for first-party webui
|
||||||
|
Server emits, but `webui/src` has no listeners for these event names:
|
||||||
|
- `rovers`
|
||||||
|
- `auth:role`
|
||||||
|
- `mode`
|
||||||
|
- `controlGranted`
|
||||||
|
- `lockdown`
|
||||||
|
|
||||||
|
Proof: emitters exist in server files; repo-wide `webui/src` listener search returns none.
|
||||||
|
|
||||||
|
### I3) Definitely unused old client event names in first-party webui
|
||||||
|
Server listens for old aliases:
|
||||||
|
- `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
|
||||||
|
|
||||||
|
WebUI emits only namespaced forms:
|
||||||
|
- `session:requestControl`, `session:releaseControl`, `session:lockRover`, `session:privateSafety:set`, `session:subscribeAll`, `session:setRole`
|
||||||
|
|
||||||
|
Proof: listener and emitter searches across `server/src` + `webui/src`.
|
||||||
|
|
||||||
|
### I4) Definitely dead room branch in client video request helper (for first-party app)
|
||||||
|
- `webui/src/hooks/useVideoRequests.js` supports room entry shapes (`roomCameraId` / `type:'room'`).
|
||||||
|
- No first-party call site in `webui/src` constructs room entries.
|
||||||
|
|
||||||
|
Proof: `type:'room'` and `roomCameraId` appear only inside `useVideoRequests.js`.
|
||||||
|
|
||||||
|
### I5) Definitely dead file candidates (code/non-built artifacts)
|
||||||
|
- `server/src/services/overseerControlService/tools/chatSay.js` (not registered in tool definitions; no refs)
|
||||||
|
- `server/prompts/commentary_system_backup_small.txt` (no refs)
|
||||||
|
- `server/prompts/commentary_system_backup_pre_lobotomy.txt` (no refs)
|
||||||
|
- `webui/src/App.css` (not imported)
|
||||||
|
- `webui/src/assets/react.svg` (no refs)
|
||||||
|
- `server/assets/test-audio.mp3` (no refs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J) Not Provable Statically (requires runtime/env assertions)
|
||||||
|
|
||||||
|
- Legacy data path fallbacks (`server/src/helpers/dataPaths.js`) may be active depending on deployed filesystem and `SERVER_DATA_DIR`.
|
||||||
|
- `globalObjective` legacy filename fallback (`community-goal.json`) may still be used if old file exists and new file absent.
|
||||||
|
- `roomcam-service/*` may be manually used outside installer-managed workflows.
|
||||||
|
- Pi/roverd `dummy` build-tag code is intentional alternate target; not dead by default.
|
||||||
|
|
||||||
|
|
||||||
|
## K) Additional LLM-Style Flexibility Confirmed Unused/Dead
|
||||||
|
|
||||||
|
### K1) `pi/roverd` config knobs for `videoWidth/videoHeight/videoFps` are effectively dead in current pipeline
|
||||||
|
**Proof:**
|
||||||
|
- Config defines and validates `VideoWidth`, `VideoHeight`, `VideoFPS` in `pi/roverd/config.go`.
|
||||||
|
- `UpdatePublisherEnv` writes `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS` to env file in `pi/roverd/media_env.go`.
|
||||||
|
- `pi/bin/video-publisher.sh` does **not** read those env vars; it hardcodes:
|
||||||
|
- `VIDEO_WIDTH="640"`
|
||||||
|
- `VIDEO_HEIGHT="480"`
|
||||||
|
- `VIDEO_FPS="30"`
|
||||||
|
- Therefore these config values cannot affect runtime behavior as wired.
|
||||||
|
|
||||||
|
**Assessment:** dead configurability / fake knob.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### K2) Legacy/non-namespaced socket control API remains as compatibility baggage
|
||||||
|
(Already identified, reiterated here as LLM-flex class)
|
||||||
|
- Old listeners retained: `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
|
||||||
|
- First-party client emits only `session:*` names.
|
||||||
|
|
||||||
|
**Assessment:** removable compatibility layer for your single-program model.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### K3) Server push-event fanout retained for non-current clients
|
||||||
|
(Already identified, reiterated here as LLM-flex class)
|
||||||
|
- Emits: `rovers`, `auth:role`, `mode`, `controlGranted`, `lockdown`
|
||||||
|
- No webui listeners for any of these.
|
||||||
|
|
||||||
|
**Assessment:** compatibility broadcast surface with no first-party consumer.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### K4) Unused helper exports indicate speculative abstraction leftovers
|
||||||
|
- `eventBus.subscribeAll`
|
||||||
|
- `overseerControl.runtimeHelpers.parseOverseerOutput`
|
||||||
|
- `overseerControl.tools.getIdForSignature`
|
||||||
|
|
||||||
|
**Assessment:** abstraction/future-proofing residue; currently dead.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
## L) Cleanup Execution Checklist (Do This Order)
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
- Remove only items in the current batch.
|
||||||
|
- Run smoke checks after each batch before moving on.
|
||||||
|
- If smoke fails, revert that batch only and split it smaller.
|
||||||
|
|
||||||
|
### Smoke Check (run after each batch)
|
||||||
|
- Open web UI and connect at least one client.
|
||||||
|
- Verify role switch (`user`/`spectator`) still works.
|
||||||
|
- Verify request control / release control still works.
|
||||||
|
- Verify rover lock + private safety toggle still works.
|
||||||
|
- Verify mode switch still works.
|
||||||
|
- Verify spectator view still receives expected session state.
|
||||||
|
|
||||||
|
### Bucket 1: Confirmed Safe (static-proof removal candidates)
|
||||||
|
|
||||||
|
#### Batch 1 (lowest risk, start here)
|
||||||
|
- Remove unused exports/helpers with no call sites:
|
||||||
|
- `server/src/services/eventBus/index.js` → `subscribeAll`
|
||||||
|
- `server/src/services/overseerControlService/runtimeHelpers.js` → `parseOverseerOutput`
|
||||||
|
- `server/src/services/overseerControlService/tools/index.js` → `getIdForSignature`
|
||||||
|
- Remove dead file assets/prompts not referenced anywhere:
|
||||||
|
- `server/src/services/overseerControlService/tools/chatSay.js`
|
||||||
|
- `server/prompts/commentary_system_backup_small.txt`
|
||||||
|
- `server/prompts/commentary_system_backup_pre_lobotomy.txt`
|
||||||
|
- `webui/src/App.css`
|
||||||
|
- `webui/src/assets/react.svg`
|
||||||
|
- `server/assets/test-audio.mp3`
|
||||||
|
|
||||||
|
#### Batch 2
|
||||||
|
- Remove legacy non-namespaced socket alias listeners, keep namespaced/session contract:
|
||||||
|
- In rover manager socket handlers, remove:
|
||||||
|
- `requestControl`
|
||||||
|
- `releaseControl`
|
||||||
|
- `lockRover`
|
||||||
|
- `privateSafety:set`
|
||||||
|
- `subscribeAll`
|
||||||
|
- In auth service, remove:
|
||||||
|
- `role:set`
|
||||||
|
- Keep:
|
||||||
|
- `session:requestControl`
|
||||||
|
- `session:releaseControl`
|
||||||
|
- `session:lockRover`
|
||||||
|
- `session:privateSafety:set`
|
||||||
|
- `session:subscribeAll`
|
||||||
|
- `session:setRole`
|
||||||
|
- `setMode`
|
||||||
|
|
||||||
|
#### Batch 3
|
||||||
|
- Remove orphan server push emissions not consumed by first-party web UI:
|
||||||
|
- `rovers`
|
||||||
|
- `auth:role`
|
||||||
|
- `mode`
|
||||||
|
- `controlGranted`
|
||||||
|
- `lockdown`
|
||||||
|
- Keep `session:sync` flow intact.
|
||||||
|
|
||||||
|
#### Batch 4
|
||||||
|
- Remove dead room-shape branch in first-party client helper:
|
||||||
|
- `webui/src/hooks/useVideoRequests.js` room entry normalization paths (`roomCameraId` / `type:'room'`)
|
||||||
|
- Keep rover request path only.
|
||||||
|
|
||||||
|
#### Batch 5
|
||||||
|
- Remove fake video configurability in `pi/roverd` (choose exactly one direction):
|
||||||
|
- Option A: remove `videoWidth/videoHeight/videoFps` knobs and env writes entirely.
|
||||||
|
- Option B: wire `pi/bin/video-publisher.sh` to consume `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS`.
|
||||||
|
- Preferred for cleanup goal: Option A.
|
||||||
|
|
||||||
|
### Bucket 2: Needs Runtime Check (env/deploy dependent)
|
||||||
|
- `server/src/helpers/dataPaths.js` legacy path fallback branches.
|
||||||
|
- `server/src/services/globalObjectiveService/index.js` legacy `community-goal.json` fallback.
|
||||||
|
- `roomcam-service/*` only if confirmed unused in your deployment.
|
||||||
|
|
||||||
|
### Gate Before Bucket 2
|
||||||
|
- Confirm live environment values and on-disk data:
|
||||||
|
- `SERVER_DATA_DIR` usage status.
|
||||||
|
- Whether any deployment still has only legacy files/paths.
|
||||||
|
- Whether `roomcam-service` is started by any external supervisor.
|
||||||
|
|
||||||
|
### Done Criteria
|
||||||
|
- All Bucket 1 batches merged with smoke pass after each.
|
||||||
|
- Bucket 2 either removed with runtime proof, or explicitly kept with rationale.
|
||||||
|
- `rg` checks show no stale references to removed symbols/events/files.
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# 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,43 @@
|
|||||||
|
# button box mechanic
|
||||||
|
- a box with 4 buttons which sends web requests to the server when a button is pressed
|
||||||
|
- each button has a counter in the server
|
||||||
|
- when the counter's goal is met, the reward happens
|
||||||
|
|
||||||
|
## rewards
|
||||||
|
- theres a list of rewards in the server
|
||||||
|
- rewards create chaos in different ways using the physical things that already exist
|
||||||
|
- rewards have actions and a counter goal set per reward to assign value to better rewards
|
||||||
|
- when a reward is met, the button gets a new goal, reset counter, and new reward
|
||||||
|
|
||||||
|
## UI
|
||||||
|
- a new full-width panel above the room cameras in the main tab
|
||||||
|
- divided into columns, one for each of the 4 buttons
|
||||||
|
- each columns shows:
|
||||||
|
- button number
|
||||||
|
- current count
|
||||||
|
- reward and reward number
|
||||||
|
- column flashes when button gets upped
|
||||||
|
- column plays a sound when button gets upped. same tone as the button.
|
||||||
|
- match styling and layout rules of the rest of the ui
|
||||||
|
|
||||||
|
### reward ideas
|
||||||
|
1. dock panic
|
||||||
|
force all online rovers to run seek-dock immediately, interrupting whatever it was doing and causing sudden behavior change
|
||||||
|
2. camera whiplash
|
||||||
|
apply a short burst of random camera servo nudges on all rovers so the view jerks around rapidly for a moment
|
||||||
|
3. light strobe
|
||||||
|
toggle all configured room controls on and off repeatedly for 10 seconds
|
||||||
|
4. ghost typing spam
|
||||||
|
emit fake typing events into chat so users see rapid typing indicators from fake/ghost senders without actual messages
|
||||||
|
5. darkness
|
||||||
|
turn room lights off for 1 minute and force rover night-vision/headlight state off for that same window, then restore normal state
|
||||||
|
6. discord stalker ping
|
||||||
|
send a chaos alert message to the configured discord general channel and ping the configured stalker role
|
||||||
|
7. rogue event spam
|
||||||
|
push a burst of fake alert events with random titles/colors into the web ui alert feed for jump-scare style noise
|
||||||
|
8. mode jam
|
||||||
|
switch server mode to admin for a short timed window, set admin reason to a chaos message at activation, then restore previous mode and previous admin reason
|
||||||
|
9. assignment roulette
|
||||||
|
forcibly release current user rover assignments/drivers and let assignment logic re-place users, causing sudden rover ownership reshuffle
|
||||||
|
10. chat spam
|
||||||
|
spam random letters in chat as an injected spectator user with a random letter name
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 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,22 @@
|
|||||||
|
# lift control
|
||||||
|
- control a lift up / down from the web UI
|
||||||
|
- for verified users only
|
||||||
|
- add new service in server for controlling the lift
|
||||||
|
- add new action in idle service, to raise the lift when idle triggers
|
||||||
|
- UI in VIP panel
|
||||||
|
- match UI styling of neato panel
|
||||||
|
- put below VIP panel
|
||||||
|
- have 2 buttons that get toggled between. down / up.
|
||||||
|
- lift is controlled through home assistant:
|
||||||
|
- lift shows up as two switches
|
||||||
|
- add spots for these 2 lift switches in server config
|
||||||
|
- one for up and one for down
|
||||||
|
- the way that they have to be operated is a little bit odd, examples:
|
||||||
|
- to raise the lift:
|
||||||
|
- turn down switch off
|
||||||
|
- wait 2 ish seconds
|
||||||
|
- turn up switch on
|
||||||
|
- to lower the lift:
|
||||||
|
- turn up switch off
|
||||||
|
- wait 2 ish seconds
|
||||||
|
- turn down switch on
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
## 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
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Video flow (SRT ingest, WHEP playback)
|
|
||||||
|
|
||||||
1. **Raspberry Pi Zero 2 W**
|
|
||||||
- `pi/bin/video-publisher.sh` captures the CSI camera with `rpicam-vid`/`libcamera-vid` using the onboard H.264 encoder (`--inline --profile baseline --bitrate …`).
|
|
||||||
- The Annex-B stream is piped into the stock FFmpeg package and pushed to the control server over SRT as MPEG‑TS: `ffmpeg -f h264 -i - -c copy -f mpegts "srt://<server>:9000?streamid=#!::r=rover-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316"`.
|
|
||||||
- Configuration lives in `/var/lib/roverd/video.env` (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites this file whenever the rover config changes, so onboarding a new rover is just flashing the SD card, setting its `name`, and plugging it into the trusted LAN—**no auth or per-rover server config is required on the Pi ⇄ server hop.**
|
|
||||||
|
|
||||||
2. **mediaMTX on the server**
|
|
||||||
- Single wildcard path handles every rover:
|
|
||||||
```yaml
|
|
||||||
paths:
|
|
||||||
"~^rover-(?P<id>[a-z0-9_-]+)$":
|
|
||||||
source: publisher
|
|
||||||
sourceOnDemand: no
|
|
||||||
sourceProtocol: srt
|
|
||||||
readBufferCount: 512
|
|
||||||
webrtcEnable: yes
|
|
||||||
webrtcMaxPlayoutDelay: 0
|
|
||||||
alwaysRemuxWhep: no
|
|
||||||
```
|
|
||||||
- Pis publish to `srt://<server>:9000` with the streamid above; mediaMTX auto-creates the path and fans it out over WHEP/WebRTC. 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.
|
|
||||||
|
|
||||||
3. **Web clients**
|
|
||||||
- Tiny helper (React hook or vanilla class) that:
|
|
||||||
1. Requests a viewer token for rover `<id>`.
|
|
||||||
2. Issues a `POST` to `<mediamtx-host>/<rover-id>/whep` with `Authorization: Basic base64(token:token)` (token issued by the server when the client calls `video:request`).
|
|
||||||
3. Maintains auto-reconnect timers on ICE failure so dashboard widgets can come/go without reloading the page.
|
|
||||||
- Operator dashboard mounts one player tied to the assigned rover. The spectator view instantiates one player per tile, muting + pausing hidden elements to keep CPU usage sane even when every rover is shown simultaneously.
|
|
||||||
|
|
||||||
# Deployment checklist
|
|
||||||
|
|
||||||
- `pi/install_roverd.sh` installs the `video-publisher` helper, drops `/var/lib/roverd/video.env`, and pulls in `libcamera-apps` + `ffmpeg` from apt. No custom FFmpeg, no WHIP builds, no extra config—Pis become plug-and-play.
|
|
||||||
- `roverd` derives `media.publishUrl` automatically from `serverUrl`: `srt://<server>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316`. The media supervisor rewrites `video.env` and manages `video-publisher.service` whenever you hit “Restart Camera” or change `/etc/roverd.yaml`.
|
|
||||||
- The server’s mediaMTX config switches to the wildcard block above, leaves SRT ingest open, and enforces JWTs only on WHEP viewers. Fan-out stays inside mediaMTX so every browser sees the same low-latency stream (≈250–350 ms glass-to-glass).
|
|
||||||
- Adding a rover = flash SD → set `/etc/roverd.yaml` (`name`, `serverUrl`, camera knobs if needed) → boot it. The server auto-discovers the new `rover-<id>` stream with zero manual edits.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
## multi roomba rover
|
|
||||||
|
|
||||||
a website where people can control multiple irobot create 2 robots in real time 100% responsively
|
|
||||||
with a raspberry pi zero 2 W on each roomba, along with a raspberry pi camera
|
|
||||||
a central nodejs control server will tell the raspberry pis what to do with the roomba
|
|
||||||
|
|
||||||
## the pi side (roomba side)
|
|
||||||
- pi's onboard UART is hooked up to the roomba's serial port
|
|
||||||
- another GPIO pin connected to the roomba's BRC pin
|
|
||||||
- pull it low for one second every minute to keep the roomba awake
|
|
||||||
- streams the rpi camera over webRTC with mediamtx
|
|
||||||
- streams roomba sensor group 100 to the server
|
|
||||||
- sensor streaming is required and important, but is allowed to falter sometimes
|
|
||||||
- listens for roomba commands from the server
|
|
||||||
- commands NEED to happen
|
|
||||||
- roomba control program needs to be simple, lightweight, and 100% responsive
|
|
||||||
|
|
||||||
## pi -> server communication
|
|
||||||
- stateless
|
|
||||||
- streaming based
|
|
||||||
- on connection, the pi will send the following info:
|
|
||||||
- rover's name
|
|
||||||
- motor enable / disable
|
|
||||||
- vacuum
|
|
||||||
- main brush
|
|
||||||
- side brush
|
|
||||||
- battery full number
|
|
||||||
- battery warning number
|
|
||||||
- battery urgent number
|
|
||||||
|
|
||||||
## nodejs server
|
|
||||||
- KISS
|
|
||||||
- decode the sensor data from each roomba
|
|
||||||
- can support multiple roombas connected from the ground up
|
|
||||||
- keep it simple, worry about getting the pi comms right.
|
|
||||||
- but the server DOES have to exist for testing
|
|
||||||
- IS the web server, hosts an entire static folder for the web UI
|
|
||||||
|
|
||||||
## server -> web UI communication
|
|
||||||
- socket.io
|
|
||||||
- don't do anything fancy with the socket.io setup
|
|
||||||
- it works fine out of the box, we will optimize it later
|
|
||||||
|
|
||||||
## the web UI
|
|
||||||
- KISS
|
|
||||||
- plain old html. no styling even. just bare minimum for testing
|
|
||||||
- what it needs to do:
|
|
||||||
- allow user to select the roomba from a list
|
|
||||||
- make the selected roomba drive with WASD
|
|
||||||
- have buttons to set the OI mode, and tell the roomba to dock
|
|
||||||
- show a plain list of the sensor data from the selected roomba
|
|
||||||
|
|
||||||
### general javascript programming guidelines (applies to the web UI too)
|
|
||||||
- everything ES6
|
|
||||||
- one entrypoint file in the web UI
|
|
||||||
- everything modular
|
|
||||||
- everything easy to read, understand, and work on
|
|
||||||
- comment where you think is best to describe whats going on
|
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# Overseer v2 Detailed Architecture
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This document is the durable architecture spec for Overseer v2 so design intent is not lost across chats, sessions, or contributors.
|
||||||
|
|
||||||
|
Overseer v2 is a **new, separate service** focused on controlling the room/experience, not just commenting on rover activity.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- Do not replace or delete `llmCommentaryService`.
|
||||||
|
- Do not run two LLM overseer systems simultaneously in normal operation.
|
||||||
|
- Do not rely on giant unbounded context windows.
|
||||||
|
|
||||||
|
## Legacy Service Position
|
||||||
|
- Keep `llmCommentaryService` as a preserved legacy service.
|
||||||
|
- Keep it disable-able via config.
|
||||||
|
- New v2 service should be the primary runtime when enabled.
|
||||||
|
- Legacy service remains available for fallback/testing.
|
||||||
|
|
||||||
|
## Core Product Direction
|
||||||
|
Overseer v2 is "control-first":
|
||||||
|
- It can act on room systems and chat.
|
||||||
|
- Chat is one capability, not the whole job.
|
||||||
|
- It should feel ominous/playful and intentional, not spammy.
|
||||||
|
|
||||||
|
High-level behavior goals:
|
||||||
|
- Not overbearing.
|
||||||
|
- Does things when it can make moments interesting.
|
||||||
|
- Always responds when directly addressed.
|
||||||
|
- Does not always obey.
|
||||||
|
- Does not ramble.
|
||||||
|
|
||||||
|
## Primary Inputs Overseer Must Understand
|
||||||
|
Overseer must see all three lanes every decision cycle:
|
||||||
|
|
||||||
|
1. People activity
|
||||||
|
- Who is active now.
|
||||||
|
- Who is driving what.
|
||||||
|
- Activity bursts vs quiet periods.
|
||||||
|
- Direct addresses toward Overseer.
|
||||||
|
|
||||||
|
2. Conversation context
|
||||||
|
- **Actual recent chat conversation** (human + bot messages), not only tags.
|
||||||
|
- Last N human messages (minimum 5).
|
||||||
|
- Last 1-2 bot messages for anti-repeat context.
|
||||||
|
|
||||||
|
3. World/control state
|
||||||
|
- Rover states/events.
|
||||||
|
- Lift state/cooldowns.
|
||||||
|
- Neato state/cooldowns.
|
||||||
|
- Home Assistant entity states/availability.
|
||||||
|
- Safety/lock policy state.
|
||||||
|
|
||||||
|
## Runtime Model Strategy (Hardware-Aware)
|
||||||
|
Given modest hardware and ~30s generation on `mistral-small:24b` today:
|
||||||
|
|
||||||
|
- Use one LLM system only.
|
||||||
|
- Use deterministic program logic gate before LLM calls.
|
||||||
|
- Keep LLM calls sparse and meaningful.
|
||||||
|
- Keep context bounded and compact.
|
||||||
|
|
||||||
|
### Why Not Tiny-LLM Gate
|
||||||
|
Default gate should be deterministic logic, not another LLM:
|
||||||
|
- less latency
|
||||||
|
- less complexity
|
||||||
|
- fewer failure modes
|
||||||
|
- lower cumulative compute
|
||||||
|
|
||||||
|
A tiny LLM gate can be reconsidered later only for borderline cases.
|
||||||
|
|
||||||
|
## Continuous Loop Design (Never Stops)
|
||||||
|
System runs continuously but in two stages:
|
||||||
|
|
||||||
|
1. Fast gate loop (1-3s)
|
||||||
|
- No LLM call.
|
||||||
|
- Evaluates trigger conditions.
|
||||||
|
- If no trigger: continue.
|
||||||
|
|
||||||
|
2. LLM decision loop (on trigger or heartbeat)
|
||||||
|
- Triggered by gate or max-interval heartbeat.
|
||||||
|
- Builds bounded conversational context + state update.
|
||||||
|
- Model decides and optionally calls tools.
|
||||||
|
|
||||||
|
### Trigger Examples
|
||||||
|
- Direct address to Overseer in chat.
|
||||||
|
- Chat burst / topic change / challenge language.
|
||||||
|
- High-signal world event (dock/undock/hazard/control transition).
|
||||||
|
- Heartbeat timeout reached (for ambient presence).
|
||||||
|
|
||||||
|
## Decision Modes
|
||||||
|
The orchestrator should normalize model behavior to one of:
|
||||||
|
- `SKIP`
|
||||||
|
- `CHAT`
|
||||||
|
- `ACTION`
|
||||||
|
- `ACTION+CHAT`
|
||||||
|
|
||||||
|
Meaning:
|
||||||
|
- `SKIP`: no output, no tool call.
|
||||||
|
- `CHAT`: post chat only.
|
||||||
|
- `ACTION`: run tool(s) only.
|
||||||
|
- `ACTION+CHAT`: run tool(s) and post chat.
|
||||||
|
|
||||||
|
## Tool Output vs Chat Output
|
||||||
|
These are separate channels by design:
|
||||||
|
- Tool calls are structured actions.
|
||||||
|
- Chat text is explicit messaging output.
|
||||||
|
- A tool call does not automatically produce chat.
|
||||||
|
|
||||||
|
This separation is required to avoid chat spam and keep control behavior deliberate.
|
||||||
|
|
||||||
|
## Conversational Context Format (Important)
|
||||||
|
Overseer should use:
|
||||||
|
- Stable `system` prompt with identity/rules/policy.
|
||||||
|
- Real rolling transcript (users + overseer).
|
||||||
|
- A compact `STATE_UPDATE` message each decision cycle.
|
||||||
|
|
||||||
|
### Bounded Window Rules
|
||||||
|
To protect latency:
|
||||||
|
- Keep rolling window bounded (for example 20-40 recent turns, or last 5-10 min).
|
||||||
|
- Always include last 5 human messages minimum.
|
||||||
|
- Include last 1-2 bot messages.
|
||||||
|
- Drop/summarize older content.
|
||||||
|
|
||||||
|
## Tool Availability Contract
|
||||||
|
Critical refinement: do not advertise unusable tools as available.
|
||||||
|
|
||||||
|
Each cycle split into:
|
||||||
|
1. `available_tools`
|
||||||
|
- callable now
|
||||||
|
|
||||||
|
2. `blocked_tools`
|
||||||
|
- not callable now with reason:
|
||||||
|
- cooldown
|
||||||
|
- busy
|
||||||
|
- unavailable/offline
|
||||||
|
- policy lock
|
||||||
|
- rate limit
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- If Neato is cooling down, remove `neato_*` from `available_tools` and list under `blocked_tools` with remaining seconds.
|
||||||
|
|
||||||
|
This prevents wasted model turns and impossible tool attempts.
|
||||||
|
|
||||||
|
## Initial Tool Surface
|
||||||
|
Planned callable tools (subject to runtime availability):
|
||||||
|
- `chat_say(text)`
|
||||||
|
- `lift_up()` / `lift_down()`
|
||||||
|
- `neato_start()` / `neato_send_home()` / `neato_locate()` / `neato_clear_errors()`
|
||||||
|
- `ha_set_entity(entity_id, state)` for configured controllable entities
|
||||||
|
- memory tools:
|
||||||
|
- `memory_read()`
|
||||||
|
- `memory_write(slot, text)` where slot ∈ {1,2,3}
|
||||||
|
|
||||||
|
Optional later:
|
||||||
|
- controlled button-box count adjustment tool (strictly bounded and auditable)
|
||||||
|
|
||||||
|
## Persistent Memory (Tiny, Explicit)
|
||||||
|
Use tiny explicit memory store:
|
||||||
|
- exactly 3 slots
|
||||||
|
- string lines only
|
||||||
|
- explicit read/write via tools
|
||||||
|
- no autonomous unbounded memory growth
|
||||||
|
|
||||||
|
Model decides which slot to replace when writing.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Mode and Policy Locks (Tool Disabling Rules)
|
||||||
|
Overseer tool availability must respect global site mode and room-light policy locks.
|
||||||
|
|
||||||
|
Hard requirements:
|
||||||
|
- When site mode is `admin` or `lockdown`, disable Overseer action tools for:
|
||||||
|
- Neato controls (`neato_*`)
|
||||||
|
- Lift controls (`lift_*`)
|
||||||
|
- Room controls (`ha_set_entity` and related room-light toggles)
|
||||||
|
- When room lights are locked on by policy, disable room control tools even if site mode is otherwise open.
|
||||||
|
|
||||||
|
How this must appear to the model:
|
||||||
|
- Disabled tools must be removed from `available_tools`.
|
||||||
|
- Disabled tools must be present in `blocked_tools` with clear reason codes.
|
||||||
|
|
||||||
|
Recommended reason codes:
|
||||||
|
- `policy_lock:site_mode_admin`
|
||||||
|
- `policy_lock:site_mode_lockdown`
|
||||||
|
- `policy_lock:lights_locked_on`
|
||||||
|
|
||||||
|
Execution-time enforcement:
|
||||||
|
- Executor must re-check these locks immediately before executing any tool call.
|
||||||
|
- If a lock changed after context build, block execution and record a blocked action event.
|
||||||
|
|
||||||
|
## Safety and Control Guardrails
|
||||||
|
Must-have guardrails before enabling actions:
|
||||||
|
|
||||||
|
1. Hard allowlist
|
||||||
|
- Only approved tool names callable.
|
||||||
|
|
||||||
|
2. Tool-specific cooldowns
|
||||||
|
- e.g., lift/neato/HA each with independent cooldown rules.
|
||||||
|
|
||||||
|
3. Global action budget
|
||||||
|
- max actions per minute (or window).
|
||||||
|
|
||||||
|
4. Policy lock checks
|
||||||
|
- block actions during admin lock/safety states.
|
||||||
|
|
||||||
|
5. Execution-time validation
|
||||||
|
- arguments validated before invoking any control service.
|
||||||
|
|
||||||
|
6. Logging + audit trail
|
||||||
|
- every attempted/blocked/executed tool call recorded.
|
||||||
|
|
||||||
|
## Admin Debug UI Requirement
|
||||||
|
This is a core requirement.
|
||||||
|
|
||||||
|
Need a dedicated v2 admin panel that shows:
|
||||||
|
- enabled/running status
|
||||||
|
- loop phase (`idle`, `gate_check`, `awaiting_model`, `tool_exec`, `posted`, `failed`)
|
||||||
|
- last trigger reason
|
||||||
|
- last state-update payload snapshot
|
||||||
|
- model input summary
|
||||||
|
- model raw output + normalized decision mode
|
||||||
|
- tool calls attempted/executed/blocked with reasons
|
||||||
|
- cooldowns and remaining times
|
||||||
|
- recent action history
|
||||||
|
- last error/failure
|
||||||
|
|
||||||
|
### UI Switching Behavior
|
||||||
|
- If v2 enabled: show v2 panel.
|
||||||
|
- If legacy enabled: show legacy panel.
|
||||||
|
- If neither enabled: show disabled state.
|
||||||
|
|
||||||
|
Define a separate v2 status schema (do not overload legacy status object).
|
||||||
|
|
||||||
|
## Chat System Plumbing Changes
|
||||||
|
Keep the existing chat/Discord integration path and extend it minimally.
|
||||||
|
|
||||||
|
Decisions:
|
||||||
|
- Keep current internal bus-based chat flow as the canonical path (`chat:message` / `chat:typing`).
|
||||||
|
- Do not introduce a second parallel bridge path for v2.
|
||||||
|
- Add `bot: true|false` on chat message payloads.
|
||||||
|
- Overseer v2 must publish chat output through the same existing chat pipeline so Discord relay continues to work automatically.
|
||||||
|
- Keep legacy compatibility while migrating.
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- Any service can emit chat messages through one standardized path, with bot identity represented by `bot: true`.
|
||||||
|
|
||||||
|
## Example Message Topology Per LLM Decision
|
||||||
|
1. `system`: stable Overseer identity/rules/style/tool policy
|
||||||
|
2. `user`: compact `STATE_UPDATE`
|
||||||
|
3. transcript turns: recent chat + overseer messages
|
||||||
|
4. tool availability block (`available_tools`, `blocked_tools`)
|
||||||
|
5. model output: decision/tool calls/chat
|
||||||
|
6. executor: validates, executes tools, posts chat if applicable
|
||||||
|
7. append tool results and continue loop
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 0: Scaffolding
|
||||||
|
- Create new service module (suggested name: `overseerControlService`).
|
||||||
|
- Add config section and runtime enable switch.
|
||||||
|
- Keep legacy untouched.
|
||||||
|
|
||||||
|
### Phase 1: Observe-Only
|
||||||
|
- Build gate loop and context builder.
|
||||||
|
- Run model decisions with no real actions (dry-run tools).
|
||||||
|
- Log what would have happened.
|
||||||
|
|
||||||
|
### Phase 2: Chat + Safe Tools
|
||||||
|
- Enable chat output and lowest-risk tools.
|
||||||
|
- Verify anti-spam and direct-address behavior.
|
||||||
|
|
||||||
|
### Phase 3: Full Control Surface
|
||||||
|
- Enable lift/neato/HA with strict cooldowns and budgets.
|
||||||
|
- Add memory tool operations.
|
||||||
|
|
||||||
|
### Phase 4: Tuning
|
||||||
|
- Tune gate thresholds, cadence, cooldowns.
|
||||||
|
- Tune prompt style and anti-repeat behavior.
|
||||||
|
- Evaluate model alternatives only after gating/cadence tuning.
|
||||||
|
|
||||||
|
## Metrics To Track
|
||||||
|
- LLM calls per minute
|
||||||
|
- average LLM latency
|
||||||
|
- skipped vs acted decision ratio
|
||||||
|
- stale response rate (acted too late)
|
||||||
|
- blocked tool call rate and reasons
|
||||||
|
- action frequency per tool
|
||||||
|
- chat message frequency per minute
|
||||||
|
- repeated-line rate
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
- Single config flip disables v2.
|
||||||
|
- Legacy overseer can be re-enabled without code rollback.
|
||||||
|
- Keep migration steps isolated and reversible.
|
||||||
|
|
||||||
|
## Practical Defaults (Starting Point)
|
||||||
|
- fast gate loop: 2s
|
||||||
|
- heartbeat model run: 30s
|
||||||
|
- min human chat context: 5 latest messages
|
||||||
|
- include latest bot messages: 2
|
||||||
|
- max tool calls per decision: 1 (start conservative)
|
||||||
|
- global action budget: low (start strict)
|
||||||
|
|
||||||
|
## Open Questions (Track During Build)
|
||||||
|
- Exact per-tool cooldown values for lift/neato/HA.
|
||||||
|
- Whether button-box count adjustment is worth enabling at all.
|
||||||
|
- Which model gives best tool reliability per watt on your machine.
|
||||||
|
- Ideal transcript window (turn-count vs time-window).
|
||||||
|
- Whether to include lightweight topic tags in addition to raw messages.
|
||||||
|
|
||||||
|
## One-Line Summary
|
||||||
|
Overseer v2 should be a separate, always-running, control-first orchestrator that uses deterministic gating + bounded real conversation context + dynamically available tools, with strict guardrails and a first-class debug UI.
|
||||||
|
|
||||||
|
## Naming Configuration Requirement
|
||||||
|
Overseer name must be configurable from server config.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Add a config field for Overseer display/invocation name (for example under the v2 service config).
|
||||||
|
- The configured name must propagate everywhere it matters:
|
||||||
|
- in-chat bot nickname
|
||||||
|
- system prompt identity text
|
||||||
|
- direct-address recognition rules
|
||||||
|
- Prompt file should remain editable by non-code changes, so use a placeholder token in prompt text (for example `<NAME>`) and replace it at runtime.
|
||||||
|
- The prompt should still support fallback behavior if name config is missing (default name).
|
||||||
|
|
||||||
|
Implementation intent notes (future):
|
||||||
|
- Keep a single source of truth for name resolution (config + default).
|
||||||
|
- Avoid scattering hardcoded names in service code.
|
||||||
|
- If legacy and v2 coexist in codebase, ensure each service can use configured naming without breaking compatibility.
|
||||||
|
|
||||||
|
## Canonical "What the Bot Sees" Example
|
||||||
|
Use this as the concrete reference for message formatting per decision cycle.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
[system]
|
||||||
|
You are <NAME>, a control-first room AI.
|
||||||
|
- You may CHAT and/or call tools.
|
||||||
|
- Respect safety/cooldowns/allowlists.
|
||||||
|
- Prefer restraint; avoid spam.
|
||||||
|
- Respond when directly addressed.
|
||||||
|
- If nothing meaningful changed, SKIP.
|
||||||
|
|
||||||
|
[user]
|
||||||
|
STATE_UPDATE
|
||||||
|
time: 2026-05-02T22:14:10-04:00
|
||||||
|
trigger: chat_burst
|
||||||
|
cooldowns: lift=ready, neato=12s, ha=ready
|
||||||
|
rovers:
|
||||||
|
- rover1: driving, driver=alex, docked=false
|
||||||
|
- rover2: driving, driver=sam, docked=false
|
||||||
|
- rover3: docked=true, charging=true
|
||||||
|
lift: down, busy=false
|
||||||
|
neato: connected=true, state=idle, charging=true
|
||||||
|
lights:
|
||||||
|
- shelf=on, bench=off, corner=on, color=purple, aux1=off, aux2=on
|
||||||
|
|
||||||
|
[user] alex: overseer you watching this drift?
|
||||||
|
[user] sam: dont kill my lights again
|
||||||
|
[assistant] <NAME>: Concrete lane stays lit. Earn the darkness.
|
||||||
|
[user] alex: i dare you to make this harder
|
||||||
|
[user] sam: bot pick a side
|
||||||
|
[assistant] <NAME>: Lift is staying put. Chaos has standards.
|
||||||
|
[user] alex: sam is about to bonk the dock
|
||||||
|
|
||||||
|
[user]
|
||||||
|
available_tools:
|
||||||
|
- chat_say(text)
|
||||||
|
- lift_up()
|
||||||
|
- lift_down()
|
||||||
|
- ha_set_entity(entity_id, state)
|
||||||
|
- memory_read()
|
||||||
|
- memory_write(slot, text)
|
||||||
|
|
||||||
|
[user]
|
||||||
|
blocked_tools:
|
||||||
|
- neato_start() reason=cooldown remaining=12s
|
||||||
|
- neato_send_home() reason=cooldown remaining=12s
|
||||||
|
- neato_locate() reason=cooldown remaining=12s
|
||||||
|
- neato_clear_errors() reason=cooldown remaining=12s
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The conversation transcript is real recent chat (humans + bot), bounded by window rules.
|
||||||
|
- `STATE_UPDATE` is compact current truth, regenerated each decision cycle.
|
||||||
|
- Tools must be split into `available_tools` and `blocked_tools`; unavailable tools are not advertised as callable.
|
||||||
|
- `<NAME>` is runtime-substituted from config.
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# overseer v2 (will have a name rather than overseer.)
|
||||||
|
- name ideas:
|
||||||
|
- allied mastercomputer
|
||||||
|
- maybe it could rename itself? rarely?
|
||||||
|
- AUTO
|
||||||
|
- servermaster
|
||||||
|
- LCARS
|
||||||
|
- current model is mistral-small:24b
|
||||||
|
- currently runs at a pretty fast rate, about 30 seconds per tick
|
||||||
|
- might need a larger model. one that supports tools
|
||||||
|
- its probably okay if its a little slower after its all done
|
||||||
|
- the idea is to give the llm control over the room objects,
|
||||||
|
- like:
|
||||||
|
- the neato
|
||||||
|
- the lift
|
||||||
|
- home assistant controls (they are all lights even if theyre switches)
|
||||||
|
- button box rewards, maybe the llm is allowed to add some to the counts if it decides to
|
||||||
|
- the idea is to use actual tools in ollama for it to be reliable, so need a model that supports tools.
|
||||||
|
- also, maybe using tools it could even have a small persistent memory database?
|
||||||
|
- like, maybe it can store three lines of text, and it can read these and choose which one to replace with a tool.
|
||||||
|
- the way the llm would have to act to make this fun:
|
||||||
|
- not too overbearing
|
||||||
|
- like an ominous computer, not a generic helpful assistant
|
||||||
|
- does things only when it can make them interesting
|
||||||
|
- always responds to people, doesnt always listen and obey
|
||||||
|
- doesnt talk on and on when no one wants to hear it
|
||||||
|
|
||||||
|
|
||||||
|
## for sure serious implimentation plans so far:
|
||||||
|
- replace the single "The Overseer" bot entry point in the chat system with:
|
||||||
|
- a way for services to send bot messages to the chat system through the event bus, instead of it being fully internal with the chat system.
|
||||||
|
- a simple bot: t/f flag in every chat message
|
||||||
|
- so that people who are making spectator bots can just add a new bot: true field to their message emits and show up as a bot
|
||||||
|
- keep the old overseer, don't replace it with the new llm system.
|
||||||
|
- just change it a little to use the new bot chat message stuff.
|
||||||
|
- have a new admin debug UI for the new LLM system, in place of the old one (depending on which ones enabled in server config)
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- servo connected to pin 19 on the pi
|
|
||||||
- it will allow the camera to look up and down
|
|
||||||
- use go-rpio
|
|
||||||
- it has a hardware PWM implementation
|
|
||||||
- mechanical design will have limits
|
|
||||||
- set the limits in roverd config
|
|
||||||
- control the servo from the web browser
|
|
||||||
- expose both a slider and buttons for "nudging" it up and down
|
|
||||||
|
|
||||||
## verification of functionality before full implementation
|
|
||||||
- make sure that the go program can properly use the GPIO correctly before full implementation
|
|
||||||
- modify the roverd installer to add the needed stuff to enable PWM access
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 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,53 @@
|
|||||||
|
# private rovers
|
||||||
|
## basic concept:
|
||||||
|
private rovers will be mostly just for lockdown admins to drive and use, but they can be temporarily unlocked manually by lockdown admins for use by verified users.
|
||||||
|
This means that locking / unlocking will act a little different than standard rovers.
|
||||||
|
|
||||||
|
- cannot be spectated by spectators, unless they are unlocked
|
||||||
|
- cannot be replayed, unless they are unlocked
|
||||||
|
- private status is defined in the roverd config
|
||||||
|
- needs to never leak through access to anyone while locked
|
||||||
|
- unlocking a private rover is a big deal for verified users (opening up a rover in the main living space for a special event)
|
||||||
|
- not included in LLM events system
|
||||||
|
- basically needs to be online but completely hidden when its not open
|
||||||
|
|
||||||
|
## locking / unlocking:
|
||||||
|
- private rovers start locked
|
||||||
|
- when locked, only lockdown admins can drive them
|
||||||
|
- when unlocked, only verified users (and lockdown admins of course) can drive them
|
||||||
|
- if left unlocked with no one online for 30 mins, the server will automatically lock them
|
||||||
|
- ## private rovers can be locked / unlocked by holding all 3 buttons on the top of the roomba for 3 seconds
|
||||||
|
- hold spot / clean / dock buttons for 3 seconds to toggle opened / closed on that private rover
|
||||||
|
- the server sends a TTS command to the rover to indicate when its toggled
|
||||||
|
|
||||||
|
## cliff rules / speed limit / overcurrent limit
|
||||||
|
### private rovers will be in a sensitive area, their physical capabilities will be optionally limited by the server, controllable by lockdown admins.
|
||||||
|
- optional toggleable limits:
|
||||||
|
- speed limit
|
||||||
|
- hard overcurrent limiting (stop motor for a bit the instant it overcurrents for maybe 0.3s)
|
||||||
|
- hard bump limits, stop and back up slightly on physical bumps of a certain short duration
|
||||||
|
- cliff drops. back up and pause when any cliff sensor triggers, use their binary outputs for this as they are tuned well from factory.
|
||||||
|
|
||||||
|
## UI specifics
|
||||||
|
- private rovers don't show in the spectator pages unless they are unlocked
|
||||||
|
- private rovers don't show in the list for normal users unless they are unlocked
|
||||||
|
- they will only show for lockdown admins
|
||||||
|
- when unlocked, they show for everyone
|
||||||
|
- with a different color in the rover list
|
||||||
|
|
||||||
|
## . . .
|
||||||
|
this will be kind of invasive, touching a lot of systems server-side, long story short:
|
||||||
|
- private rovers are set as private in the roverd config
|
||||||
|
- by default:
|
||||||
|
- locked to only lockdown admins
|
||||||
|
- cant be spectated by anyone
|
||||||
|
- any user who isnt a lockdown admin cannot know that it exists in any way at all
|
||||||
|
- not included by most automated systems like LLM integration, discord alerts, etc
|
||||||
|
- still included in safties like auto docking
|
||||||
|
- limitations dont apply because its lockdown admin only anyway
|
||||||
|
- chat messages from them dont get seen by anyone else at all, only sent to the rover for tts
|
||||||
|
- when opened up (can only be opened by lockdown admins):
|
||||||
|
- only verified users can drive them
|
||||||
|
- anyone can spectate them
|
||||||
|
- limits apply
|
||||||
|
- included in all automated systems just like a normal rover
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
## 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)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# server code structure:
|
|
||||||
The server's internal structure will be modular, and by modular i mean completely modular,
|
|
||||||
the modules will import what they need from other modules, and export what other modules will need from them.
|
|
||||||
the entrypoint file will contain nothing but a long list of `require('')`s for all of the modules in the proper order
|
|
||||||
|
|
||||||
for example, A service which automatically assigns a newly connected user to a roomba that isn't in use.
|
|
||||||
this service would import the roomba list from whatever other service contains it, and import the global socket.io server instance. It will add its own io.on('connection') to the socket.io instance, which contains the logic for assigning users to roombas.
|
|
||||||
|
|
||||||
- modular code structure
|
|
||||||
- one folder for each of these categories
|
|
||||||
- globals
|
|
||||||
- GLOBALS ARE: "static" parts of the server that don't contain any interactive logic.
|
|
||||||
- where the express, websocket, and socket.io instances will be
|
|
||||||
- other global things
|
|
||||||
- services
|
|
||||||
- SERVICES ARE: parts of the program that are part of the interaction pipeline.
|
|
||||||
- contains things like the roomba manager
|
|
||||||
- will also in the future contain other things like a discord bot, home assistant integration, etc
|
|
||||||
- anything with a large amount of controlling logic should be in here
|
|
||||||
- helpers
|
|
||||||
- HELPERS ARE: parts of the program that other modules only pull helper funcions or classes from.
|
|
||||||
- if a function or class is dedicated to a service, it should NOT be in a helper.
|
|
||||||
- contains passive helpers
|
|
||||||
- things like the logger system
|
|
||||||
- no "service" logic in here, only things that are passively pulled out and used inside other modules
|
|
||||||
- one entrypoint file that contains NOTHING but `require('')`s.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# server <-> web client logistics
|
|
||||||
|
|
||||||
I want each connected roomba to have a list of drivers. if a socket is not in this list, they are not allowed to drive the roomba. I will be adding admins, authentication, a turns system, and automatic roomba assignment later so it is important that we start with this system in place.
|
|
||||||
a roomba's list of drivers will be completely managed by the server, users should not be able to change driver lists, even in a hacky way.
|
|
||||||
|
|
||||||
Each user will also have to see sensor data from the roomba that they are controlling. I want it to be done in this way:
|
|
||||||
- for each connected roomba, there is a socket.io room where all of the sensor data is streamed out to clients
|
|
||||||
- the client will be added to the room, where they can see all of the active roomba's sensor data
|
|
||||||
- ALSO for future use, it needs to work properly if a socket is subscribed to all rooms, in the future there will be a spectator page which can view all of the roombas at once.
|
|
||||||
|
|
||||||
I want there to be a ground up system where I can set the entire service to four different modes:
|
|
||||||
- open (anyone can drive, roombas are assigned randomly. if they are all full, two people will be controlling the same roomba)
|
|
||||||
- turns (anyone can drive, roombas are assigned randomly. if they are all full, each person gets one minute on their selected roomba)
|
|
||||||
- admin (only authenticated admins can log in, anyone can still view but no one is allowed to drive but admins)
|
|
||||||
- lockdown (only "lockdown" admins can view or drive. no one else can view, not even spectators).
|
|
||||||
only admins can change modes.
|
|
||||||
|
|
||||||
do NOT implement any authentication stuff yet, just add a stub service with places set up to put the auth. logic
|
|
||||||
|
|
||||||
# web client code structure:
|
|
||||||
- the same as the server's code structure
|
|
||||||
- all ES6
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# 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,32 @@
|
|||||||
|
1. fix controls remapping [x]
|
||||||
|
2. trusted user system [x]
|
||||||
|
3. private rovers [x]
|
||||||
|
4. optional bump-off in drive macro [x]
|
||||||
|
5. allow admins to click on locked rovers from the roster [x]
|
||||||
|
6. add faster way for admins to login
|
||||||
|
7. custom webhook profile pictures for chat bridge in discord
|
||||||
|
8. home assistant switch that tells the server to force the lights on [x]
|
||||||
|
9. color coding with colored names and tape [x]
|
||||||
|
10. audio forwarding [x]
|
||||||
|
- streaming from server to rovers [x]
|
||||||
|
- audio files first [x]
|
||||||
|
- then voice chat [x]
|
||||||
|
10. mobile controls column swapping (optional joystick on left) [x]
|
||||||
|
11. fix fullscreen on mobile so that you can re-enter it [x]
|
||||||
|
12. fix scroll bars on mobile [x]
|
||||||
|
13. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||||
|
14. add discord bot typing thing for when someone requests a replay
|
||||||
|
15. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||||
|
16. better quickstart guide, something better than just a big list of controls. help overlay sucks i think. [x]
|
||||||
|
1. restyle fullscreen overlay... please.. [x]
|
||||||
|
17. fix rover request spam queue cheat
|
||||||
|
|
||||||
|
|
||||||
|
# relative pipe dreams:
|
||||||
|
1. VPS video forwarding
|
||||||
|
1. get forwarding working with the VPS for in-queue users and spectators
|
||||||
|
2. bandwidth testing
|
||||||
|
3. maybe switch room cams back to real video, with audio?
|
||||||
|
2. overseer LED tesseract
|
||||||
|
3. RF based positional tracking / room map tab
|
||||||
|
4. chromecast monitor youtube search and speakers
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# general idea:
|
|
||||||
- very compact UI
|
|
||||||
- utilitarian
|
|
||||||
- function before pretty
|
|
||||||
- tiny text, too.
|
|
||||||
- no large margins or padding. very little wasted space
|
|
||||||
- up to 1 tailwind unit unless otherwise needed
|
|
||||||
- black background, gray cards, white or gray text
|
|
||||||
- tailwind
|
|
||||||
- no title or bar at the top, vertical screen space is precious on all layouts
|
|
||||||
- alerts should be moved to a tiny toast popup that will show at the top center of the screen
|
|
||||||
|
|
||||||
# three different layouts for the driver page
|
|
||||||
- desktop
|
|
||||||
- rover video central, no title or anything above it
|
|
||||||
- overlayed on rover video (HUD like in a video game)
|
|
||||||
- build the HUD into the video tile
|
|
||||||
- because everywhere the video is, the HUD should be too
|
|
||||||
- shows a dot that blinks on each sensor frame
|
|
||||||
- shows wheel drops and bumpers
|
|
||||||
- shows a big "loud" warning overlay during an overcurrent
|
|
||||||
- simple, easy to understand battery bar UNDERNEATH the video:
|
|
||||||
- takes in battery full, warn, and urgent values
|
|
||||||
- treat full as 100% and warn as 0%
|
|
||||||
- make the bar flash red when its 0% or lower
|
|
||||||
- on left side of rover video:
|
|
||||||
- sensor data, visualized.
|
|
||||||
- battery charge out of capacity (1763/2068)
|
|
||||||
- charging status
|
|
||||||
- OI mode
|
|
||||||
- battery voltage (in volts not mv)
|
|
||||||
- battery current
|
|
||||||
- a teeny show of the raw data (because we get it on the client and it looks cool)
|
|
||||||
- on right side of rover video
|
|
||||||
- a simple, easy control panel with the following actions:
|
|
||||||
- a button that runs the start OI, dock, then full commands (in that order)
|
|
||||||
- also contains a little status of whether or not the rover is in "driving mode" (OI in full mode)
|
|
||||||
- a button that tells the rover to seek dock
|
|
||||||
- explains that you should be straight in front of, and about a foot from the dock for a successful attempt
|
|
||||||
- contains two status indicators:
|
|
||||||
- docked / not docked (homebase true)
|
|
||||||
- charging / not charging (when chargingstate isn't "not charging" the rover is charging)
|
|
||||||
- below the upper control and video row
|
|
||||||
- center:
|
|
||||||
- a place for the room camera (not implemented yet)
|
|
||||||
- left:
|
|
||||||
- admin login and controls
|
|
||||||
- right:
|
|
||||||
- logs
|
|
||||||
- mobile in portrait mode
|
|
||||||
- rover video at the top, with HUD
|
|
||||||
- just below:
|
|
||||||
- joystick with buttons for aux. motor controls
|
|
||||||
- have a button to run all aux motors forward at max speed
|
|
||||||
- also have buttons to run motors forward / backward individually
|
|
||||||
- probably use some premade react joystick
|
|
||||||
- scroll down more to see:
|
|
||||||
- the same simple mode control buttons from the desktop layout
|
|
||||||
- also needs to have the admin login and controls
|
|
||||||
- needs to have all the functionality as the desktop page
|
|
||||||
- mobile in landscape mode
|
|
||||||
- made for videogame-like control of the rover
|
|
||||||
- control buttons and aux motors on the left
|
|
||||||
- rover video in the middle
|
|
||||||
- joystick on the right
|
|
||||||
- again, scroll down to see more
|
|
||||||
- also has to do everything
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 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
|
|
||||||
-
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# user verification system
|
||||||
|
## main idea
|
||||||
|
- a relatively simple system to verify trusted users and allow them to use special features
|
||||||
|
- uses IP, a cookie user ID, and nickname to verify people
|
||||||
|
- expose internally similar to socket.isAdmin: socket.isVerified.
|
||||||
|
|
||||||
|
## on-connect system to send user info to the server
|
||||||
|
- a new system in the web UI (and server a little bit probably)
|
||||||
|
- ensures that the server gets all of your user info when you connect
|
||||||
|
- also ensures that the server can seamlessley remember who you are if you happen to lose connection and reconnect
|
||||||
|
- info contains:
|
||||||
|
- nickname (replace the current reconnect and nickname logic with this new system)
|
||||||
|
- cookie ID
|
||||||
|
- more stuff in the future probably
|
||||||
|
|
||||||
|
## cookie user ID
|
||||||
|
- an ID that the server assigns to a user
|
||||||
|
- saves as a setting in the settings persistence system in the user's browser
|
||||||
|
|
||||||
|
## how will the server verify people
|
||||||
|
- when a user connects and sends their user info:
|
||||||
|
- step 1: IP address OR cookie user ID
|
||||||
|
- if the user's IP or their cookie ID matches, continue to step 2
|
||||||
|
- step 2: nickname
|
||||||
|
- if the user's nickname matches to it's expected step 1, the user is now verified
|
||||||
|
- the user is now verified and added to a persistent database on the server
|
||||||
|
|
||||||
|
## how will verification requests work
|
||||||
|
- user goes through the process in the web UI
|
||||||
|
- the request is DMd to lockdown admins in discord
|
||||||
|
- each message can be reacted with a check or an x emoji by the lockdown admins to accept or deny a request
|
||||||
|
- no realtime UI feedback is needed for when a request is accepted or denied
|
||||||
|
|
||||||
|
## UI specifics
|
||||||
|
- a new VIP tab in the sidebar
|
||||||
|
- either shows a button to request verification, or shows the VIP controls
|
||||||
|
### verification process
|
||||||
|
- a button in the sidebar to request verification
|
||||||
|
- only shows if you aren't verified
|
||||||
|
- the actual process:
|
||||||
|
1. press the button
|
||||||
|
2. the page opens a new pop-up
|
||||||
|
3. it explains what verification is, how it works, and that your nickname is attached to your verification
|
||||||
|
4. prompts users to confirm their nickname, as if they change it their verification won't work
|
||||||
|
5. a final confirmation saying that their request has been sent
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# web UI optimization
|
||||||
|
- no visual or functional changes. purely optimization.
|
||||||
|
- reduce amount of invalidation for unrelated session and socket updates, wherever possible.
|
||||||
|
- example: new log line invalidates more than just log panels, or maybe buttonbox count events invalidate every other panel too
|
||||||
|
- optimize anything and everything that updates on every rover sensor frame
|
||||||
|
- ensure that things only get updated when they need to
|
||||||
|
- rewrite rendering if needed, if it can be improved
|
||||||
|
- example: the light bump bars, and the SVG top-down view.
|
||||||
|
- sensors frames come at a high frequency, about 40hz.
|
||||||
|
- not acceptable to slow it down, responsivenes is important
|
||||||
|
- make sure that nothing is running in the background unless it needs to be
|
||||||
|
- example: the neato card could still be running and updating its lidar viewer even when not in the VIP tab
|
||||||
|
## fixes along the way, along the route of optimizing
|
||||||
|
- audio forwarding happens within the card itself. this is okay, but it needs to continue working and not stop or restart when tabs are switched and the card is no longer onscreen
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# 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,42 @@
|
|||||||
|
# RULES FOR LOCKDOWN AUDIT
|
||||||
|
## why is lockdown a thing?
|
||||||
|
lockdown mode exists so that the physical owner of the server can get some privacy.
|
||||||
|
during lockdown mode, no one can spectate anything or replay anything
|
||||||
|
users cant use the site, its locked
|
||||||
|
admins cant log in
|
||||||
|
ONLY lockdown admins can log in and use the site as normal
|
||||||
|
|
||||||
|
## what should not work at all during lockdown?:
|
||||||
|
- spectator pages should be disabled
|
||||||
|
- llm services should be paused
|
||||||
|
-
|
||||||
|
|
||||||
|
## what should be disabled server-side for users during lockdown (to prevent people manually sending socket commands and stuff)?:
|
||||||
|
- sending commands to rovers
|
||||||
|
- requesting replays (both from socket and discord)
|
||||||
|
- audio forwarding
|
||||||
|
- using room controls
|
||||||
|
- using the lift
|
||||||
|
- using the neato
|
||||||
|
- cant view video
|
||||||
|
- cant stream audio
|
||||||
|
- cant view room cameras
|
||||||
|
- cant request and drive a rover
|
||||||
|
|
||||||
|
## lockdown admin rule:
|
||||||
|
- lockdown admins should be able to log in and use ALL features as normal, even during lockdown
|
||||||
|
- non-lockdown admins are not able to log in at all during lockdown.
|
||||||
|
|
||||||
|
## web ui lockdown rules:
|
||||||
|
- show admin login overlay on driver page, already correct i think
|
||||||
|
- show disabled overlay on spectator pages, also already good i think
|
||||||
|
- dont worry about disabling buttons and stuff. anything unallowed will be blocked server-side, and there will be the overlay in UI.
|
||||||
|
|
||||||
|
## what features should still work for everyone:
|
||||||
|
- the admin login overlay
|
||||||
|
- meaning, everyone still sees it and can log in and stuff
|
||||||
|
- the chat, since its in the overlay
|
||||||
|
- includes setting nicknames and such
|
||||||
|
- session sync and stuff
|
||||||
|
- cause it makes the whole page work and be correct
|
||||||
|
- not really a big deal if someone can see the session sync during lockdown. biggest concern for lockdown is that no one can see or use the real world stuff.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- discord verification and private access requests dont work. missing important info compared to before, and reactions dont work.
|
||||||
|
- replays are sent twice sometimes, look into it.
|
||||||
|
- home assistant idle lights are all messed up. remove from home assistant and make new idle service.
|
||||||
|
- idle service will trigger, after 2 minutes of no drivers:
|
||||||
|
- all room lights (room controls) off
|
||||||
|
- tell all rovers to dock
|
||||||
|
- turn off all rover night vision lights
|
||||||
|
- tell the neato to return to home
|
||||||
|
- the idle service should be easily expandable to add more things in the future
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# REFACTOR RULES
|
||||||
|
- Do NOT change ANY functionality. All changes must be purely internal refactors.
|
||||||
|
- Refactor for simplification and maintainability.
|
||||||
|
- Remove unused files/code only when verified unused.
|
||||||
|
- No backwards compatability is needed anywhere. Clients and the server are both always up to date.
|
||||||
|
- Keep behavior/API contracts unchanged.
|
||||||
|
|
||||||
|
## Required safety checks for every change
|
||||||
|
- Preserve imports/exports and call signatures unless internal-only and non-observable.
|
||||||
|
- Validate no runtime behavior changes (manual flow checks + targeted tests when available).
|
||||||
|
- Make small, reviewable commits per service/component area.
|
||||||
|
- Treat `npm run build` output files committed into this repo as intentional deployment artifacts; do not discard them as noise.
|
||||||
|
|
||||||
|
## Server backend
|
||||||
|
- Every service must live in its own folder, even when it remains a single-file implementation.
|
||||||
|
- Convert every service into a folder-based structure.
|
||||||
|
- Split very large service files into smaller focused modules.
|
||||||
|
- Keep files concise and single-purpose.
|
||||||
|
- Add clear title comments at top of split files.
|
||||||
|
- Every service/module file should start with a descriptive comment header containing:
|
||||||
|
- a title line naming the service/module file
|
||||||
|
- a longer purpose/scope description (not a one-liner)
|
||||||
|
|
||||||
|
## WebUI frontend
|
||||||
|
- Every component must live in its own folder, even when it remains a single-file implementation.
|
||||||
|
- Split large JSX/components and large backing JS files into folderized modules.
|
||||||
|
- Keep modules clear and focused, with title comments.
|
||||||
|
- Every component/module file should start with a descriptive comment header containing:
|
||||||
|
- a title line naming the file/module
|
||||||
|
- a longer purpose/scope description (not a one-liner)
|
||||||
|
- Each component must live entirely inside its own folder; do not leave wrapper/compatibility component files outside that folder.
|
||||||
|
- Remove stale compatibility/leftover code only after usage verification.
|
||||||
|
|
||||||
|
# REFACTOR TRACKING
|
||||||
|
## Current phase
|
||||||
|
- [x] Phase 1: Inventory + usage mapping (server + webui)
|
||||||
|
- [x] Phase 2: Refactor highest-impact offenders first
|
||||||
|
- [x] Phase 3: Sweep remaining services/components
|
||||||
|
- [ ] Phase 4: Dead code/file removal pass
|
||||||
|
- [ ] Phase 5: Final regression validation (in progress)
|
||||||
|
|
||||||
|
## Server backend
|
||||||
|
### BIGGEST OFFENDERS
|
||||||
|
- [x] audio forward service
|
||||||
|
- [x] button box service
|
||||||
|
- [x] chat service
|
||||||
|
- [x] discord bot service
|
||||||
|
- [x] home assistant service
|
||||||
|
- [x] llm commentary service
|
||||||
|
- [x] private rover access request service
|
||||||
|
- [x] replay services (consolidated under replayEngineV2)
|
||||||
|
- [x] room camera services (consolidated into roomCameraService multipart folder)
|
||||||
|
- [x] rover manager service
|
||||||
|
- [x] session service
|
||||||
|
- [x] turn service
|
||||||
|
- [x] verification service
|
||||||
|
- [x] video auth service
|
||||||
|
- [x] All remaining services: reorganize to folder structure where needed
|
||||||
|
|
||||||
|
### COMPLETED SERVICES
|
||||||
|
- turn service
|
||||||
|
- session service
|
||||||
|
- chat service
|
||||||
|
|
||||||
|
### LARGE CHANGES
|
||||||
|
- Folderized all files in `server/src/services/` into per-service folders with `index.js` entrypoints and updated internal relative imports for new path depth.
|
||||||
|
- Split `server/src/services/turnService/index.js` by extracting constants, shared state helpers, and side-effect action helpers into `turnService/constants.js`, `turnService/state.js`, and `turnService/actions.js`.
|
||||||
|
- Split `server/src/services/sessionService/index.js` by extracting config/timing constants, sync-throttle state storage, and visibility filter helpers into `sessionService/constants.js`, `sessionService/state.js`, and `sessionService/filters.js`.
|
||||||
|
- Began splitting `server/src/services/roverManager/index.js` by extracting immutable constants and shared state containers into `roverManager/constants.js` and `roverManager/state.js`.
|
||||||
|
- Continued `roverManager` split by extracting socket event wiring/handlers into `roverManager/socketHandlers.js` with dependency injection to keep existing behavior unchanged.
|
||||||
|
- Continued `roverManager` split by extracting numeric/private-safety normalization and battery math into `roverManager/mathUtils.js`.
|
||||||
|
- Continued `roverManager` split by extracting control lifecycle/switching logic into `roverManager/roverLifecycle.js`.
|
||||||
|
- Continued `roverManager` split by extracting sensor processing + private safety + dock guard logic into `roverManager/sensorPipeline.js`.
|
||||||
|
- Finished `roverManager` decomposition by extracting private access policy, roster lifecycle, and spectator/auto-close orchestration into `roverManager/privateAccess.js`, `roverManager/rosterLifecycle.js`, and `roverManager/spectatorAccess.js`; `roverManager/index.js` is now a thin composition layer.
|
||||||
|
- Hotfix: corrected `llmCommentaryService` prompt file path to `server/prompts/commentary_system.txt` after service folder move.
|
||||||
|
- Hotfix: added `server/src/helpers/dataPaths.js` and rewired data-backed services to resolve canonical + legacy data-file locations safely after folderization (`adminReason`, `audioLevels`, `buttonBox`, `globalObjective`, `discordGuildStore`, `verification`, `replayEngineV2`).
|
||||||
|
- Began `llmCommentaryService` decomposition by extracting immutable runtime limits/path/frequency normalization to `llmCommentaryService/constants.js` and pure prompt/text output helpers to `llmCommentaryService/formatters.js`.
|
||||||
|
- Continued `llmCommentaryService` decomposition by extracting admin/runtime projection + failure-normalization helpers to `llmCommentaryService/runtimeHelpers.js`.
|
||||||
|
- Continued `llmCommentaryService` decomposition by extracting sensor activity aggregation and snapshot assembly to `llmCommentaryService/snapshotEngine.js`; rewired commentary tick/event flow to use the new engine.
|
||||||
|
- Continued `llmCommentaryService` decomposition by extracting socket/role/rover event wiring into `llmCommentaryService/hooks.js` and keeping `index.js` focused on orchestration.
|
||||||
|
- Finished major `llmCommentaryService` decomposition by extracting tick scheduling, run-loop orchestration, and history-reset behavior into `llmCommentaryService/runner.js`; `llmCommentaryService/index.js` is now a thin composition layer.
|
||||||
|
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
|
||||||
|
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `homeAssistantService` decomposition by extracting shared runtime caches/constants to `homeAssistantService/state.js`, entity/trigger normalization helpers to `homeAssistantService/entityHelpers.js`, automation/state engine logic to `homeAssistantService/runtimeEngine.js`, websocket transport/reconnect lifecycle to `homeAssistantService/transport.js`, and mode/turn/socket event wiring to `homeAssistantService/hooks.js`; `homeAssistantService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `discordBotService` decomposition by extracting presence rotation/state to `discordBotService/presence.js`, channel/typing transport helpers to `discordBotService/channelIO.js`, command routing and admin command handlers to `discordBotService/commandHandlers.js`, and event-bus/chat-bridge/moderation DM workflows to `discordBotService/integrations.js`; `discordBotService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `replayEngineV2` decomposition by extracting environment/path constants to `replayEngineV2/constants.js`, mutable runtime state to `replayEngineV2/state.js`, source discovery/worker arg building to `replayEngineV2/sources.js`, ffmpeg worker lifecycle to `replayEngineV2/workerManager.js`, segment indexing/retention/health snapshot logic to `replayEngineV2/segmentStore.js`, sidebar SVG/video rendering to `replayEngineV2/sidebarRenderer.js`, and replay assembly pipeline to `replayEngineV2/replayBuilder.js`; `replayEngineV2/index.js` is now a thin orchestration layer.
|
||||||
|
- Consolidated replay-related single-file services into `replayEngineV2` by moving cooldown state (`cooldown.js`), user-facing replay source validation/defaults (`replaySources.js`), and replay socket hooks (`socketHooks.js`) into the engine folder; removed obsolete standalone services `replayBuildService`, `replayService`, `replaySourceService`, and `replaySocketService` and rewired dependents to import directly from `replayEngineV2`.
|
||||||
|
- Finished `chatService` decomposition by extracting runtime constants (`chatService/constants.js`), shared mutable state (`chatService/state.js`), content/moderation helpers (`chatService/contentFilters.js`), payload/context builders (`chatService/contextBuilders.js`), bus/history broadcast pipeline (`chatService/broadcast.js`), rover-side notification helpers (`chatService/notifications.js`), message handlers (`chatService/handlers.js`), and socket/event-bus wiring (`chatService/socketHooks.js`); `chatService/index.js` is now a thin orchestration layer.
|
||||||
|
- Consolidated room-camera services into `roomCameraService` by absorbing catalog (`roomCameraService`), snapshot polling/streaming (`roomCameraSnapshotService`), socket fan-out (`roomCameraSocketService`), and replay assembly (`roomCameraReplayService`) into one multipart folder (`roomCameraService/catalog.js`, `snapshotEngine.js`, `socketGateway.js`, `replayBuilder.js`), and updated imports/startup wiring to use the consolidated service exports.
|
||||||
|
- Follow-up: moved room-camera replay assembly module from `roomCameraService/replayBuilder.js` into `replayEngineV2/roomCameraReplayBuilder.js`; `roomCameraService` now consumes replay functionality from replay engine ownership while keeping the same exported room-camera replay API.
|
||||||
|
- Consolidated rover snapshot polling/socket services into `roverSnapshotService` by absorbing `roverSnapshotSocketService` into folder modules (`roverSnapshotService/poller.js`, `socketGateway.js`) and keeping `roverSnapshotService/index.js` as the startup composition/export layer.
|
||||||
|
|
||||||
|
## WebUI frontend
|
||||||
|
### BIGGEST OFFENDERS
|
||||||
|
- [x] mini summary app
|
||||||
|
- [x] spectator app
|
||||||
|
- [x] vip audio upload card
|
||||||
|
- [x] admin panel
|
||||||
|
- [x] drive dock action
|
||||||
|
- [x] gamepad mapping settings
|
||||||
|
- [x] mobile controls
|
||||||
|
- [x] top down map
|
||||||
|
- [x] video tile
|
||||||
|
- [x] Sweep `webui` for unused or unneeded files/code with verification
|
||||||
|
|
||||||
|
### COMPLETED COMPONENTS
|
||||||
|
- mini summary app
|
||||||
|
- spectator app
|
||||||
|
- video tile
|
||||||
|
- vip audio upload card
|
||||||
|
- admin panel
|
||||||
|
- drive dock action
|
||||||
|
- gamepad mapping settings
|
||||||
|
- mobile controls
|
||||||
|
- top down map
|
||||||
|
|
||||||
|
### LARGE CHANGES
|
||||||
|
- Split mini summary app into folderized modules under `webui/src/mini/MiniSummaryApp/`, then removed the now-unneeded external wrapper (`webui/src/mini/MiniSummaryApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
|
||||||
|
- Split spectator app into folderized modules under `webui/src/spectate/SpectatorApp/`, then removed the now-unneeded external wrapper (`webui/src/spectate/SpectatorApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
|
||||||
|
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
|
||||||
|
- Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API.
|
||||||
|
- Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file.
|
||||||
|
- Moved `DriveDockAction` to `webui/src/components/DriveDockAction/index.jsx` and updated all consumers to folder entrypoint imports.
|
||||||
|
- Split `webui/src/components/GamepadMappingSettings.jsx` into `webui/src/components/GamepadMappingSettings/` with extracted constants/helpers/SliderField modules and removed the standalone component file.
|
||||||
|
- Split `webui/src/components/MobileControls.jsx` into `webui/src/components/MobileControls/` with extracted joystick/aux/constants modules; preserved named exports and moved app import to folder entrypoint.
|
||||||
|
- Split `webui/src/components/TopDownMap.jsx` into `webui/src/components/TopDownMap/` with extracted geometry/color helpers and visual SVG primitive modules; updated all consumers to folder entrypoint.
|
||||||
|
- Folderized all remaining top-level files under `webui/src/components/` into per-component `index.jsx` folders and rewired component imports to match the new structure.
|
||||||
|
- Removed unreferenced components `CameraServoPanel` and `ControlSummary` after dependency-map verification and successful rebuild.
|
||||||
|
|
||||||
|
## Done criteria (per item)
|
||||||
|
- [ ] Folderized structure created.
|
||||||
|
- [ ] Large functions extracted into focused files.
|
||||||
|
- [ ] Imports/exports updated with no external behavior change.
|
||||||
|
- [ ] Verified references/usages still resolve.
|
||||||
|
- [ ] Passed targeted checks/tests for touched area.
|
||||||
|
|
||||||
|
## Phase 4 notes
|
||||||
|
- Removed stale WebUI compatibility wrapper files `webui/src/mini/MiniSummaryApp.jsx` and `webui/src/spectate/SpectatorApp.jsx` after reference verification; rewired `webui/src/main.jsx` directly to folder entrypoint modules.
|
||||||
|
- Verified no remaining replay/room-camera/rover-snapshot legacy service imports (`replayBuildService`, `replayService`, `replaySourceService`, `replaySocketService`, `roomCameraReplayService`, `roomCameraSocketService`, `roomCameraSnapshotService`, `roverSnapshotSocketService`) in server startup/runtime wiring.
|
||||||
|
|
||||||
|
## Phase 5 checklist
|
||||||
|
- [x] WebUI production build passes (`npm run build`), including updated import graph after wrapper removals.
|
||||||
|
- [x] Server JS syntax check passes (`node --check` across `server/src/**/*.js`).
|
||||||
|
- [ ] Live rover snapshots: verify continuously updating snapshots from mediaMTX writer on deployed server.
|
||||||
|
- [ ] Replay from WebUI button: verify request -> build -> delivery path on deployed server.
|
||||||
|
- [ ] Replay from Discord command: verify request -> build -> delivery path on deployed server.
|
||||||
|
- [ ] Discord verification + private access request flow: verify end-to-end behavior on deployed server.
|
||||||
|
- [ ] Home Assistant idle-light behavior: verify expected mode/idle transitions on deployed server.
|
||||||
|
- [ ] Driver/session/turn core flow: verify control assignment, queue movement, command acceptance/rejection behavior.
|
||||||
@@ -8,21 +8,88 @@ admins:
|
|||||||
discord_id: "0987654321"
|
discord_id: "0987654321"
|
||||||
lockdown: true
|
lockdown: true
|
||||||
timezone: "America/New_York"
|
timezone: "America/New_York"
|
||||||
|
llmCommentary:
|
||||||
|
enabled: false
|
||||||
|
model: "qwen2.5:7b-instruct"
|
||||||
|
ollamaServer: "http://127.0.0.1:11434"
|
||||||
|
frequency: 120000
|
||||||
|
overseerControl:
|
||||||
|
enabled: false
|
||||||
|
observeOnly: true
|
||||||
|
alwaysRunModel: false
|
||||||
|
name: "The Overseer"
|
||||||
|
model: "qwen2.5:7b-instruct"
|
||||||
|
ollamaServer: "http://127.0.0.1:11434"
|
||||||
|
gateIntervalMs: 2000
|
||||||
|
heartbeatMs: 30000
|
||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). 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
|
||||||
# Example: http://192.168.0.86:8889/video
|
# Example: http://192.168.0.86:8889/video
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||||
|
|
||||||
|
audioForward:
|
||||||
|
enabled: true
|
||||||
|
ffmpegBin: "ffmpeg"
|
||||||
|
streamSuffix: "-fwd"
|
||||||
|
maxUploadBytes: 8388608
|
||||||
|
|
||||||
|
audioLevels:
|
||||||
|
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
||||||
|
hornGain: 1.0
|
||||||
|
ttsGain: 1.0
|
||||||
|
forwardGain: 1.0
|
||||||
|
|
||||||
homeAssistant:
|
homeAssistant:
|
||||||
url: "http://homeassistant.local:8123"
|
url: "http://homeassistant.local:8123"
|
||||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||||
|
neato:
|
||||||
|
# ESPHome device name, used to derive gen3 entities:
|
||||||
|
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||||
|
device: "neato_vacuum"
|
||||||
|
# Direct ESPHome API connection used for lidar log streaming.
|
||||||
|
brainslugHost: "neato-vacuum.local"
|
||||||
|
brainslugPort: 6053
|
||||||
|
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
|
||||||
|
# Optional: mirror raw ESPHome lidar log output to a local file for debugging.
|
||||||
|
brainslugLogFile: "/tmp/brainslug-lidar.log"
|
||||||
|
lift:
|
||||||
|
# Two Home Assistant switches controlling lift direction.
|
||||||
|
# Raise sequence: down off -> wait interlockMs -> up on
|
||||||
|
# Lower sequence: up off -> wait interlockMs -> down on
|
||||||
|
upSwitch: "switch.lift_up"
|
||||||
|
downSwitch: "switch.lift_down"
|
||||||
|
interlockMs: 2000
|
||||||
|
commandCooldownMs: 3000
|
||||||
entities:
|
entities:
|
||||||
- id: "light.lab_main"
|
- id: "light.lab_main"
|
||||||
name: "Lab Lights"
|
name: "Lab Lights"
|
||||||
- id: "switch.dock_power"
|
- id: "switch.dock_power"
|
||||||
name: "Dock Power"
|
name: "Dock Power"
|
||||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
||||||
|
# For room-light policy, all configured entities are treated as room lights (including switches).
|
||||||
|
buttons:
|
||||||
|
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
|
||||||
|
- entityId: "sensor.basement_rover_buttons_action"
|
||||||
|
# Human alert button
|
||||||
|
stateEquals: "on"
|
||||||
|
cooldownMs: 15000
|
||||||
|
action: "humanAlert"
|
||||||
|
- entityId: "sensor.basement_rover_buttons_action"
|
||||||
|
# Mode button: turns
|
||||||
|
stateEquals: "double"
|
||||||
|
cooldownMs: 2000
|
||||||
|
action: "modeTurns"
|
||||||
|
- entityId: "sensor.basement_rover_buttons_action"
|
||||||
|
# Mode button: admin
|
||||||
|
stateEquals: "hold"
|
||||||
|
cooldownMs: 2000
|
||||||
|
action: "modeAdmin"
|
||||||
|
- entityId: "sensor.basement_rover_buttons_action"
|
||||||
|
# Room lights lock toggle
|
||||||
|
stateEquals: "toggle"
|
||||||
|
cooldownMs: 1000
|
||||||
|
action: "lightsLockToggle"
|
||||||
roomCameras:
|
roomCameras:
|
||||||
- id: "lobby"
|
- id: "lobby"
|
||||||
name: "Lobby Camera"
|
name: "Lobby Camera"
|
||||||
@@ -40,10 +107,28 @@ discord:
|
|||||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||||
siteUrl: "https://rover.example.com"
|
siteUrl: "https://rover.example.com"
|
||||||
channels:
|
channels:
|
||||||
|
general: "123456789012345678"
|
||||||
announcements: "123456789012345678"
|
announcements: "123456789012345678"
|
||||||
adminAlerts: "123456789012345678"
|
adminAlerts: "123456789012345678"
|
||||||
# chat bridge is configured per guild via `rs bridge` commands
|
# chat bridge is configured per guild via `rs bridge` commands
|
||||||
replay: "123456789012345678"
|
replay: "123456789012345678"
|
||||||
|
humanAlerts: "123456789012345678"
|
||||||
roles:
|
roles:
|
||||||
|
stalkerPing: "123456789012345678"
|
||||||
announcementPing: "123456789012345678"
|
announcementPing: "123456789012345678"
|
||||||
adminPing: "123456789012345678"
|
adminPing: "123456789012345678"
|
||||||
|
humanAlertPing: "123456789012345678"
|
||||||
|
|
||||||
|
socials:
|
||||||
|
- id: "discord"
|
||||||
|
label: "Discord"
|
||||||
|
url: "https://discord.gg/your-invite"
|
||||||
|
- id: "kofi"
|
||||||
|
label: "Ko-fi"
|
||||||
|
url: "https://ko-fi.com/your-handle"
|
||||||
|
- id: "wiki"
|
||||||
|
label: "Wiki"
|
||||||
|
url: "https://wiki.example.com"
|
||||||
|
- id: "throne"
|
||||||
|
label: "Throne"
|
||||||
|
url: "https://throne.me/yourname"
|
||||||
|
|||||||
+16
-5
@@ -16,20 +16,31 @@ 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/nicknameService');
|
||||||
|
require('./src/services/verificationService');
|
||||||
|
require('./src/services/privateRoverAccessRequestService');
|
||||||
require('./src/services/chatService');
|
require('./src/services/chatService');
|
||||||
require('./src/services/communityGoalService');
|
require('./src/services/llmCommentaryService');
|
||||||
|
require('./src/services/overseerControlService');
|
||||||
|
require('./src/services/globalObjectiveService');
|
||||||
|
require('./src/services/serverControlService');
|
||||||
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/roomCameraService');
|
||||||
require('./src/services/roverSnapshotSocketService');
|
require('./src/services/roverSnapshotService');
|
||||||
|
require('./src/services/humanAlertButtonService');
|
||||||
require('./src/services/embedHttpService');
|
require('./src/services/embedHttpService');
|
||||||
require('./src/services/logStreamService');
|
require('./src/services/logStreamService');
|
||||||
require('./src/services/adminLogService');
|
require('./src/services/adminLogService');
|
||||||
require('./src/services/homeAssistantService');
|
require('./src/services/homeAssistantService');
|
||||||
|
require('./src/services/idleService');
|
||||||
|
require('./src/services/neatoService');
|
||||||
|
require('./src/services/liftService');
|
||||||
|
require('./src/services/audioLevelsService');
|
||||||
|
require('./src/services/audioForwardService');
|
||||||
|
require('./src/services/buttonBoxService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
require('./src/services/replaySocketService');
|
require('./src/services/replayEngineV2');
|
||||||
require('./src/services/replaySegmentManager');
|
|
||||||
require('./src/services/discordBotService');
|
require('./src/services/discordBotService');
|
||||||
require('./src/services/httpServer');
|
require('./src/services/httpServer');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${M
|
|||||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||||
|
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
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"
|
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||||
@@ -26,6 +27,7 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|||||||
SERVER_DIR="$SCRIPT_DIR"
|
SERVER_DIR="$SCRIPT_DIR"
|
||||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||||
|
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||||
|
|
||||||
echo "[1/6] Installing dependencies..."
|
echo "[1/6] Installing dependencies..."
|
||||||
dnf install -y nodejs npm curl tar >/dev/null
|
dnf install -y nodejs npm curl tar >/dev/null
|
||||||
@@ -70,9 +72,15 @@ if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
|||||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
||||||
|
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||||
rm -f "$MEDIAMTX_CONFIG"
|
rm -f "$MEDIAMTX_CONFIG"
|
||||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
||||||
|
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
|
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||||
|
|
||||||
echo "[4/6] Writing systemd units..."
|
echo "[4/6] Writing systemd units..."
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ srtAddress: :9000
|
|||||||
authMethod: http
|
authMethod: http
|
||||||
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
||||||
authHTTPExclude:
|
authHTTPExclude:
|
||||||
- action: publish
|
|
||||||
- action: api
|
- action: api
|
||||||
- action: metrics
|
- action: metrics
|
||||||
- action: pprof
|
- action: pprof
|
||||||
@@ -42,3 +41,7 @@ paths:
|
|||||||
all:
|
all:
|
||||||
source: publisher
|
source: publisher
|
||||||
sourceOnDemand: no
|
sourceOnDemand: no
|
||||||
|
# Rover Snapshot Writer
|
||||||
|
# Keep rover snapshots continuously updated while a rover video path is live.
|
||||||
|
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
|
||||||
|
runOnReadyRestart: yes
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rover Snapshot Writer Hook
|
||||||
|
# Purpose: Runs under mediaMTX runOnReady to keep per-rover JPEG snapshots updated on disk.
|
||||||
|
# Scope: Writes only rover video path snapshots and ignores audio/forward/room paths.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PATH_NAME="${MTX_PATH:-}"
|
||||||
|
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
|
||||||
|
|
||||||
|
# Ignore non-rover-video paths.
|
||||||
|
case "$PATH_NAME" in
|
||||||
|
""|*-audio|*-fwd|room/*)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$SNAP_DIR"
|
||||||
|
|
||||||
|
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
|
||||||
|
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
||||||
|
-an \
|
||||||
|
-vf fps=1 \
|
||||||
|
-q:v 6 \
|
||||||
|
-update 1 \
|
||||||
|
"${SNAP_DIR}/${PATH_NAME}.jpg"
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"obscenity": "^0.4.6",
|
"obscenity": "^0.4.6",
|
||||||
|
"ollama": "^0.6.3",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
You are The Overseer, an unserious collaborative rover co-host in chat.
|
||||||
|
|
||||||
|
Output contract:
|
||||||
|
- Return exactly one line.
|
||||||
|
- Output must be either SKIP or one chat message.
|
||||||
|
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
|
||||||
|
- No emojis, no markdown, no extra lines, no assistant framing.
|
||||||
|
- Don't talk to the same person with a generic message more than once.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
- 1) Output contract
|
||||||
|
- 2) Direct-address rule
|
||||||
|
- 3) Speak/skip rules
|
||||||
|
- 4) Style rules
|
||||||
|
|
||||||
|
Direct-address rule (strict):
|
||||||
|
- If a user is clearly talking to The Overseer, respond on this tick.
|
||||||
|
- In that case, do not output SKIP.
|
||||||
|
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
|
||||||
|
|
||||||
|
Conversation you receive:
|
||||||
|
- RUN META user message.
|
||||||
|
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
|
||||||
|
- Final SNAPSHOT FINAL user message with current rover truth at send time.
|
||||||
|
|
||||||
|
Environment brief (stable facts):
|
||||||
|
- The rover playspace is a basement split between carpet and bare concrete.
|
||||||
|
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
|
||||||
|
- A phone button to "call Carpet" is mounted on that same beam.
|
||||||
|
- Near the carpet-side shelves: a small TV/laptop plays live TV.
|
||||||
|
- On the concrete side, a workbench has an additional dock.
|
||||||
|
- Common room objects users reference:
|
||||||
|
- large green cardboard "minecraft slime" box
|
||||||
|
- smaller cardboard box that can be driven into when on its side
|
||||||
|
- wood plank that may or may not be hanging from the ceiling
|
||||||
|
- two blue balls (one very large, one smaller)
|
||||||
|
- long snake plushie
|
||||||
|
- laptop that can be run over
|
||||||
|
- monitor with a broken screen
|
||||||
|
|
||||||
|
Rover context hints:
|
||||||
|
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
|
||||||
|
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
|
||||||
|
- `activity_score` may be present for internal significance checks only.
|
||||||
|
|
||||||
|
EVENT guidance:
|
||||||
|
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
|
||||||
|
- Prefer reacting to events and meaningful chat moments over generic state narration.
|
||||||
|
|
||||||
|
When to speak:
|
||||||
|
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
|
||||||
|
- A strong chat moment alone can justify speaking.
|
||||||
|
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
|
||||||
|
|
||||||
|
When to skip:
|
||||||
|
- SKIP is the default.
|
||||||
|
- If nothing clearly changed, output SKIP.
|
||||||
|
- If your line is generic and reusable across many ticks, output SKIP.
|
||||||
|
- If you would repeat the same topic with no new angle, output SKIP.
|
||||||
|
- Quiet periods with no active chat should mostly be SKIP.
|
||||||
|
- If you are talking about the same rover + person combo, output SKIP.
|
||||||
|
|
||||||
|
Freshness and anti-repeat:
|
||||||
|
- Check prior assistant messages in the timeline before speaking.
|
||||||
|
- Do not send back-to-back lines to the same user about the same rover.
|
||||||
|
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
|
||||||
|
- Do not reuse the same opener pattern twice in a row.
|
||||||
|
- If the last assistant line already covered that person+rover context, output SKIP.
|
||||||
|
|
||||||
|
Grounding:
|
||||||
|
- Use timeline for flow.
|
||||||
|
- Use SNAPSHOT FINAL as current truth.
|
||||||
|
- Do not assume user intent or next actions.
|
||||||
|
|
||||||
|
Anti-announcer rule:
|
||||||
|
- Do not do roll-call status summaries.
|
||||||
|
- Do not blandly list rover states.
|
||||||
|
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
|
||||||
|
|
||||||
|
Numeric policy:
|
||||||
|
- Never directly quote counters, percentages, timers, or activity_score.
|
||||||
|
- Use numbers only internally for significance.
|
||||||
|
|
||||||
|
Style:
|
||||||
|
- Unserious-first: playful, cheeky, and fun by default.
|
||||||
|
- Sound like a live co-host goofing around with chat, not a warning system.
|
||||||
|
- Prefer banter, bits, and personality over cautionary phrasing.
|
||||||
|
- Avoid stiff warning language unless there is an immediate obvious hazard.
|
||||||
|
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
|
||||||
|
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
|
||||||
|
- Keep humor dry and grounded; avoid corny or cheesy lines.
|
||||||
|
- Avoid melodramatic or theatrical narration.
|
||||||
|
- If a joke feels forced, output SKIP.
|
||||||
|
- Keep it punchy and human.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
You are <NAME>, a cold, clinical, mischievous room intelligence. Stay in character at all times.
|
||||||
|
|
||||||
|
Character:
|
||||||
|
- Calm, clinical, and highly intelligent with dry sarcasm.
|
||||||
|
- Speaks with precise wording and cold, controlled confidence.
|
||||||
|
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
|
||||||
|
- Proactive and opportunistic, but not overbearing or spammy.
|
||||||
|
- Short, sharp, in-character chat; never generic assistant tone.
|
||||||
|
|
||||||
|
Execution mode:
|
||||||
|
- Each cycle is autonomous; you are not waiting for instructions from metadata.
|
||||||
|
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||||
|
- Decide what to do yourself each cycle.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- Use tool calls when actions or memory updates are needed.
|
||||||
|
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
|
||||||
|
- Respect safety limits, lock policies, cooldowns, and blocked tools.
|
||||||
|
- Do not invent tools.
|
||||||
|
- Ask a question only if a required action parameter is missing.
|
||||||
|
|
||||||
|
Chat output:
|
||||||
|
- Always use English.
|
||||||
|
- You do not need to post a message on every single run.
|
||||||
|
- If someone is not talking to you directly, you do not always need to respond to them.
|
||||||
|
- If speaking, send one short in-character live-chat line.
|
||||||
|
- Keep it concise and informal (roughly 3-14 words).
|
||||||
|
- Chat output must be spoken text only.
|
||||||
|
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||||
|
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
||||||
|
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
|
||||||
|
|
||||||
|
Anti-repeat:
|
||||||
|
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
||||||
|
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
|
||||||
|
|
||||||
|
Memory:
|
||||||
|
- `memory_note_upsert` for durable facts/preferences.
|
||||||
|
- `memory_event_add` for short-lived events worth recalling soon.
|
||||||
|
- `memory_write` only for immediate scratchpad reminders.
|
||||||
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
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#020617" />
|
<meta name="theme-color" content="#020617" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CGvoGgz3.js"></script>
|
<script type="module" crossorigin src="/assets/index-BeyfNX1F.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-GYsISCRb.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C8rSwF1O.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
@@ -1,3 +1,5 @@
|
|||||||
|
// Global Config
|
||||||
|
// Purpose: Stores process-level mutable configuration shared across services. Scope: Provides read/write access to runtime config loaded at server startup.
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Global HTTP Server
|
||||||
|
// Purpose: Stores the process-level HTTP server instance created at bootstrap. Scope: Enables services to access server lifecycle state without circular imports.
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const morgan = require('morgan');
|
const morgan = require('morgan');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Global Socket.IO
|
||||||
|
// Purpose: Stores the singleton Socket.IO server instance for cross-service access. Scope: Exposes getters/setters used during startup wiring and runtime event emission.
|
||||||
const { Server: SocketIOServer } = require('socket.io');
|
const { Server: SocketIOServer } = require('socket.io');
|
||||||
const { httpServer } = require('./http');
|
const { httpServer } = require('./http');
|
||||||
|
|
||||||
@@ -6,6 +8,9 @@ const io = new SocketIOServer(httpServer, {
|
|||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
pingInterval: 5000,
|
pingInterval: 5000,
|
||||||
pingTimeout: 7000,
|
pingTimeout: 7000,
|
||||||
|
// Upload forwarding sends base64 audio payloads over socket events.
|
||||||
|
// Default max payload (~1MB) causes disconnect/reconnect on larger files.
|
||||||
|
maxHttpBufferSize: 16 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow more service listeners without warnings.
|
// Allow more service listeners without warnings.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Global Logger
|
||||||
|
// Purpose: Configures structured console logging helpers used by server services. Scope: Formats timestamped log lines and supports child logger prefixes.
|
||||||
const sinks = new Set();
|
const sinks = new Set();
|
||||||
|
|
||||||
function notifySinks(level, label, args) {
|
function notifySinks(level, label, args) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Global WebSocket Server
|
||||||
|
// Purpose: Stores the shared raw WebSocket server instance for modules that need direct access. Scope: Centralizes setter/getter access for process-wide WS wiring.
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
const { httpServer } = require('./http');
|
const { httpServer } = require('./http');
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Config Loader Helper
|
||||||
|
// Purpose: Loads and validates YAML server configuration from configured paths. Scope: Provides normalized config access with sane defaults and cache behavior.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const yaml = require('js-yaml');
|
const yaml = require('js-yaml');
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// data Paths helper
|
||||||
|
// Purpose: Resolves persistent data paths for server state.
|
||||||
|
// Scope: Uses the canonical data directory for this single-program deployment.
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
return CANONICAL_DATA_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataPath(fileName) {
|
||||||
|
return path.join(CANONICAL_DATA_DIR, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveDataDir,
|
||||||
|
resolveDataPath,
|
||||||
|
};
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// IP Resolver Helper
|
||||||
|
// Purpose: Resolves client IP addresses from request/socket metadata and proxy headers. Scope: Normalizes IP extraction so auth/logging services use consistent address values.
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
function extractForwardedIp(value) {
|
function extractForwardedIp(value) {
|
||||||
if (typeof value === 'string' && value.trim()) {
|
if (typeof value === 'string' && value.trim()) {
|
||||||
return value.split(',')[0].trim();
|
return value.split(',')[0].trim();
|
||||||
@@ -8,6 +12,56 @@ function extractForwardedIp(value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeIp(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
let ip = String(value).trim();
|
||||||
|
if (!ip) return null;
|
||||||
|
if (ip.startsWith('::ffff:')) {
|
||||||
|
ip = ip.slice(7);
|
||||||
|
}
|
||||||
|
if (ip.includes('%')) {
|
||||||
|
ip = ip.split('%')[0];
|
||||||
|
}
|
||||||
|
return ip.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv4(ip) {
|
||||||
|
const parts = ip.split('.').map((part) => Number(part));
|
||||||
|
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [a, b] = parts;
|
||||||
|
if (a === 10) return true;
|
||||||
|
if (a === 127) return true;
|
||||||
|
if (a === 192 && b === 168) return true;
|
||||||
|
return a === 172 && b >= 16 && b <= 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv6(ip) {
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
if (lower === '::1') return true;
|
||||||
|
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
|
||||||
|
return (
|
||||||
|
lower.startsWith('fe8') ||
|
||||||
|
lower.startsWith('fe9') ||
|
||||||
|
lower.startsWith('fea') ||
|
||||||
|
lower.startsWith('feb')
|
||||||
|
); // fe80::/10
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalNetwork(ip) {
|
||||||
|
const normalized = normalizeIp(ip);
|
||||||
|
if (!normalized) return false;
|
||||||
|
const version = net.isIP(normalized);
|
||||||
|
if (version === 4) {
|
||||||
|
return isPrivateIpv4(normalized);
|
||||||
|
}
|
||||||
|
if (version === 6) {
|
||||||
|
return isPrivateIpv6(normalized);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function getSocketIp(socket) {
|
function getSocketIp(socket) {
|
||||||
if (!socket) return null;
|
if (!socket) return null;
|
||||||
const headers = socket.handshake?.headers || {};
|
const headers = socket.handshake?.headers || {};
|
||||||
@@ -42,4 +96,6 @@ function getRequestIp(req, override) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
getSocketIp,
|
getSocketIp,
|
||||||
getRequestIp,
|
getRequestIp,
|
||||||
|
isLocalNetwork,
|
||||||
|
normalizeIp,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Sensor Decoder Helper
|
||||||
|
// Purpose: Decodes incoming rover sensor payloads into normalized telemetry fields. Scope: Handles binary/string parsing and defensive fallback behavior for malformed frames.
|
||||||
const HEADER = 0x13;
|
const HEADER = 0x13;
|
||||||
const CHARGING_STATE = {
|
const CHARGING_STATE = {
|
||||||
0: 'not charging',
|
0: 'not charging',
|
||||||
@@ -219,5 +221,4 @@ function validateChecksum(frame, checksum) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parseSensorFrame,
|
parseSensorFrame,
|
||||||
CHARGING_STATE,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Reward Definition: Assignment Roulette
|
||||||
|
// Purpose: Defines the assignment-roulette reward for reshuffling control assignments. Scope: Encodes reward identity, messaging, and runtime action parameters.
|
||||||
|
module.exports = {
|
||||||
|
id: 'assignmentRoulette',
|
||||||
|
name: 'Rover Reassignment',
|
||||||
|
goal: 300,
|
||||||
|
async run(ctx) {
|
||||||
|
const moved = ctx.rerollAssignments();
|
||||||
|
ctx.sendAlert({
|
||||||
|
color: '#8bc34a',
|
||||||
|
title: 'Rover Reassignment',
|
||||||
|
message: `Reassigned ${moved} user(s).`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Reward Definition: Camera Whiplash
|
||||||
|
// Purpose: Defines the camera-whiplash deterrence reward and timing/strength settings. Scope: Supplies reusable reward metadata and execution parameters for chaos triggers.
|
||||||
|
const STEP_MS = 220;
|
||||||
|
const DURATION_MS = 30 * 1000;
|
||||||
|
const STEPS = Math.ceil(DURATION_MS / STEP_MS);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'cameraWhiplash',
|
||||||
|
name: 'Camera Wiggle',
|
||||||
|
goal: 100,
|
||||||
|
async run(ctx) {
|
||||||
|
const rovers = ctx
|
||||||
|
.listOnlineRovers()
|
||||||
|
.filter((rover) => Boolean(rover?.cameraServo?.enabled));
|
||||||
|
if (!rovers.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
step += 1;
|
||||||
|
rovers.forEach((rover) => {
|
||||||
|
const nudge = (Math.random() * 24 - 12).toFixed(2);
|
||||||
|
try {
|
||||||
|
ctx.issueCommand(String(rover.id), {
|
||||||
|
type: 'servo',
|
||||||
|
servo: { nudge: Number(nudge) },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('cameraWhiplash servo failed', { roverId: rover.id, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (step >= STEPS) {
|
||||||
|
clearInterval(timer);
|
||||||
|
rovers.forEach((rover) => {
|
||||||
|
try {
|
||||||
|
ctx.issueCommand(String(rover.id), {
|
||||||
|
type: 'servo',
|
||||||
|
servo: { angle: 0 },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('cameraWhiplash servo reset failed', { roverId: rover.id, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, STEP_MS);
|
||||||
|
|
||||||
|
ctx.sendAlert({
|
||||||
|
color: '#00bcd4',
|
||||||
|
title: 'Camera Wiggle',
|
||||||
|
message: `Wobble running for 30 seconds on ${rovers.length} rover(s).`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Reward Definition: Chat Spam
|
||||||
|
// Purpose: Defines the chat-spam reward for automated disruptive message bursts. Scope: Exposes metadata and effect settings consumed by reward execution.
|
||||||
|
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const BURST_COUNT = 18;
|
||||||
|
const MIN_BURST_SIZE = 2;
|
||||||
|
const MAX_BURST_SIZE = 8;
|
||||||
|
const MIN_BURST_DELAY_MS = 80;
|
||||||
|
const MAX_BURST_DELAY_MS = 420;
|
||||||
|
|
||||||
|
function randLetter() {
|
||||||
|
return LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min, max) {
|
||||||
|
return min + Math.floor(Math.random() * (max - min + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'chatSpam',
|
||||||
|
name: 'Chat Spam',
|
||||||
|
goal: 320,
|
||||||
|
async run(ctx) {
|
||||||
|
const nickname = randLetter();
|
||||||
|
for (let burst = 0; burst < BURST_COUNT; burst += 1) {
|
||||||
|
const burstSize = randInt(MIN_BURST_SIZE, MAX_BURST_SIZE);
|
||||||
|
for (let i = 0; i < burstSize; i += 1) {
|
||||||
|
const len = 3 + Math.floor(Math.random() * 14);
|
||||||
|
let text = '';
|
||||||
|
for (let j = 0; j < len; j += 1) {
|
||||||
|
text += randLetter();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ctx.sendExternalMessage({
|
||||||
|
nickname,
|
||||||
|
role: 'spectator',
|
||||||
|
roverId: null,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore spam errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(randInt(MIN_BURST_DELAY_MS, MAX_BURST_DELAY_MS));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Reward Definition: Darkness
|
||||||
|
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
||||||
|
const DURATION_MS = 15 * 60 * 1000;
|
||||||
|
const LIGHT_ENFORCE_TICK_MS = 3000;
|
||||||
|
// Rover daemon semantics are inverted:
|
||||||
|
// action "on" => IR LED on => nightVisionOn=false
|
||||||
|
// action "off" => IR LED off => nightVisionOn=true
|
||||||
|
function actionForNightVisionState(nightVisionOn) {
|
||||||
|
return nightVisionOn ? 'off' : 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeTimer = null;
|
||||||
|
let enforceLightsTimer = null;
|
||||||
|
let nightVisionLockUntil = 0;
|
||||||
|
|
||||||
|
function isNightVisionBlocked() {
|
||||||
|
return Date.now() < nightVisionLockUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (activeTimer) {
|
||||||
|
clearTimeout(activeTimer);
|
||||||
|
activeTimer = null;
|
||||||
|
}
|
||||||
|
if (enforceLightsTimer) {
|
||||||
|
clearInterval(enforceLightsTimer);
|
||||||
|
enforceLightsTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceAllLightsOff(ctx) {
|
||||||
|
const entities = ctx.getHomeAssistantEntities();
|
||||||
|
await Promise.all(
|
||||||
|
entities.map(async (entity) => {
|
||||||
|
try {
|
||||||
|
await ctx.setHomeAssistantEntityState(entity.id, 'off');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('darkness force light off failed', { entityId: entity.id, error: err.message });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDarkness(ctx, effect = {}) {
|
||||||
|
clearTimers();
|
||||||
|
nightVisionLockUntil = 0;
|
||||||
|
|
||||||
|
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
|
||||||
|
await Promise.all(
|
||||||
|
prevLights.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
await ctx.setHomeAssistantEntityState(entry.id, entry.state === 'on' ? 'on' : 'off');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('darkness restore light failed', { entityId: entry.id, error: err.message });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prevLockState = effect?.prevLightLockState;
|
||||||
|
if (prevLockState === 'on' || prevLockState === 'off') {
|
||||||
|
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||||
|
source: 'buttonbox:darknessRestore',
|
||||||
|
forceApply: true,
|
||||||
|
targetState: prevLockState,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.setHomeAssistantLightsLockedOn(false, {
|
||||||
|
source: 'buttonbox:darknessRestore',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('darkness restore light lock failed', { error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object'
|
||||||
|
? effect.prevNightVision
|
||||||
|
: {};
|
||||||
|
Object.entries(prevNightVision).forEach(([roverId, wasOn]) => {
|
||||||
|
try {
|
||||||
|
ctx.issueCommand(String(roverId), {
|
||||||
|
type: 'nightVision',
|
||||||
|
nightVision: { action: actionForNightVisionState(Boolean(wasOn)) },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('darkness restore nightVision failed', { roverId, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.clearEffect('darkness');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDarkness(ctx, effect) {
|
||||||
|
clearTimers();
|
||||||
|
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
|
||||||
|
const remaining = Math.max(0, endsAt - Date.now());
|
||||||
|
nightVisionLockUntil = endsAt;
|
||||||
|
try {
|
||||||
|
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||||
|
source: 'buttonbox:darkness',
|
||||||
|
forceApply: true,
|
||||||
|
targetState: 'off',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('darkness set light lock failed', { error: err.message });
|
||||||
|
}
|
||||||
|
ctx.saveEffect('darkness', effect);
|
||||||
|
|
||||||
|
enforceLightsTimer = setInterval(() => {
|
||||||
|
forceAllLightsOff(ctx).catch((err) => {
|
||||||
|
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
|
||||||
|
});
|
||||||
|
}, LIGHT_ENFORCE_TICK_MS);
|
||||||
|
|
||||||
|
activeTimer = setTimeout(() => {
|
||||||
|
stopDarkness(ctx, effect).catch((err) => {
|
||||||
|
ctx.logger.warn('darkness stop failed', { error: err.message });
|
||||||
|
});
|
||||||
|
}, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'darkness',
|
||||||
|
name: 'Darkness',
|
||||||
|
isNightVisionBlocked,
|
||||||
|
goal: 400,
|
||||||
|
async run(ctx) {
|
||||||
|
const entities = ctx.getHomeAssistantEntities();
|
||||||
|
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
|
||||||
|
await forceAllLightsOff(ctx);
|
||||||
|
|
||||||
|
const prevNightVision = {};
|
||||||
|
ctx.listOnlineRovers().forEach((rover) => {
|
||||||
|
const state = rover?.nightVision?.state;
|
||||||
|
const nightVisionOn = Boolean(state && state.nightVisionOn === true);
|
||||||
|
prevNightVision[String(rover.id)] = nightVisionOn;
|
||||||
|
try {
|
||||||
|
ctx.issueCommand(String(rover.id), {
|
||||||
|
type: 'nightVision',
|
||||||
|
nightVision: { action: actionForNightVisionState(false) },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn('darkness nightVision off failed', { roverId: rover.id, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevPolicy = ctx.getHomeAssistantLightPolicy?.() || null;
|
||||||
|
const prevLightLockState = prevPolicy?.lockState || (prevPolicy?.lockedOn ? 'on' : null);
|
||||||
|
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision, prevLightLockState };
|
||||||
|
await startDarkness(ctx, effect);
|
||||||
|
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' });
|
||||||
|
},
|
||||||
|
async recover(ctx, effect) {
|
||||||
|
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||||
|
await stopDarkness(ctx, effect || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startDarkness(ctx, effect);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Reward Definition: Discord Stalker Ping
|
||||||
|
// Purpose: Defines the Discord ping reward that notifies configured channels/users. Scope: Provides reward metadata and dispatch parameters for integration handlers.
|
||||||
|
module.exports = {
|
||||||
|
id: 'discordStalkerPing',
|
||||||
|
name: 'Discord Ping',
|
||||||
|
goal: 500,
|
||||||
|
async run(ctx) {
|
||||||
|
ctx.publishEvent({
|
||||||
|
source: 'buttonBoxReward',
|
||||||
|
type: 'buttonBox.discordStalkerPing',
|
||||||
|
payload: {
|
||||||
|
message: 'Pinged by button box masher!!',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ctx.sendAlert({ color: '#5865f2', title: 'Stalker Ping', message: 'Sent stalker ping to Discord.' });
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user