Compare commits

...
301 Commits
Author SHA1 Message Date
legop3 40d6ca9bf0 okayy.. 2025-12-02 00:32:52 -05:00
legop3 c129f38e6b okay, ws AND polling... 2025-12-02 00:30:33 -05:00
legop3 0da5d72d91 try websockets only... 2025-12-02 00:29:31 -05:00
legop3 1c4b9b8c3d bleehhh 2025-12-02 00:25:00 -05:00
legop3 c94abeca07 2fps 2025-12-01 23:51:10 -05:00
legop3 f14aea2070 oh no 2025-12-01 23:48:48 -05:00
legop3 1d664cbe4c wuh 2025-12-01 23:45:44 -05:00
legop3 2b2b6cbb60 the great simplification 2025-12-01 23:28:12 -05:00
legop3 c7b16d6ddd salt detected 2025-12-01 23:06:02 -05:00
legop3 d068f07c4b this is how it should have been... 2025-12-01 23:02:59 -05:00
legop3 d9d8d44013 auh 2025-12-01 23:01:07 -05:00
legop3 335fa4e21d AAAHHHJ RATE LIMITTT 2025-12-01 22:56:06 -05:00
legop3 29bb16ba1a wuh 2025-12-01 22:46:50 -05:00
legop3 4d57e679bf add optional bayerer 2025-12-01 22:28:33 -05:00
legop3 7a36d988ba uuhpdate 2025-12-01 22:15:42 -05:00
legop3 56eba166f7 new room cam service 2025-12-01 22:13:56 -05:00
legop3 7f84a5eadf ogger 2025-12-01 22:03:14 -05:00
legop3 d8c3d8bb02 build webui. . . 2025-12-01 22:00:29 -05:00
legop3 62ab8539c6 jpeg roomcams first pass 2025-12-01 21:57:16 -05:00
legop3 42533bf63c room cam rework planning 2025-12-01 21:35:49 -05:00
legop3 e6009c2d92 volummeee!! 2025-12-01 21:26:31 -05:00
legop3 fb76ecd512 change default brc pin 2025-12-01 21:19:47 -05:00
legop3 9be4a4e180 newlnes for bobis 2025-11-30 03:42:53 -05:00
legop3 d7b42acced bild 2025-11-30 03:28:48 -05:00
legop3 1003101976 move stuff round 2025-11-30 03:27:03 -05:00
legop3 c29d076f26 visceral... 2025-11-30 03:15:27 -05:00
legop3 757684f383 planning 2025-11-30 02:55:59 -05:00
legop3 4321e15323 turn it up more..., 2025-11-29 23:25:12 -05:00
legop3 7389a98f8b remove old audio stuff and turn up mic 2025-11-29 23:20:09 -05:00
legop3 447b00c3cd nope 2025-11-29 23:13:13 -05:00
legop3 305becec38 wuh 2025-11-29 23:06:57 -05:00
legop3 12ba219773 remove logging 2025-11-29 22:52:08 -05:00
legop3 063afff53b ahh!! 2025-11-29 22:51:29 -05:00
legop3 0093c97873 ugh 2025-11-29 22:27:59 -05:00
legop3 a29753ef86 low voltage anode... 2025-11-29 22:24:57 -05:00
legop3 02b72cbce7 high voltage anode 2025-11-29 22:20:37 -05:00
legop3 e6e6327165 not gonna fix it but okay 2025-11-29 22:17:49 -05:00
legop3 a343c5d1e2 simplification 2025-11-29 22:12:26 -05:00
legop3 44d1920e2b guhw 2025-11-29 21:40:59 -05:00
legop3 5ad75aa465 wuhg 2025-11-29 21:34:31 -05:00
legop3 b5e44100a2 add logging 2025-11-29 21:29:11 -05:00
legop3 f6a57b788a here goes nothin 2025-11-29 21:24:18 -05:00
legop3 d00b2cf160 good old video publisher (hopefully) 2025-11-29 20:53:43 -05:00
legop3 c7c6595942 separate audio stream 2025-11-29 20:48:34 -05:00
legop3 d715c4fa54 caklkf 2025-11-29 20:02:05 -05:00
legop3 1480fcc8c1 overrun 2025-11-29 19:30:18 -05:00
legop3 c22f04bdba puh 2025-11-29 19:26:37 -05:00
legop3 96fdd62821 fling duration 2025-11-29 18:59:46 -05:00
legop3 a7d42ba561 agjakdshuflk 2025-11-29 18:48:33 -05:00
legop3 5645bfdee3 giuhu 2025-11-29 03:31:31 -05:00
legop3 82b4e14194 stupid. 2025-11-29 03:25:50 -05:00
legop3 85a42c9611 noooo moooreee hw0,0 2025-11-29 03:21:19 -05:00
legop3 40a0409f7e git 2025-11-29 03:16:54 -05:00
legop3 4d02b6fcac rovermic??? fix??? 2025-11-29 03:10:21 -05:00
legop3 b551f76c9c the fray 2025-11-29 03:02:27 -05:00
legop3 4134cd4bb1 here we go again 2025-11-29 02:31:21 -05:00
legop3 32f0fe5f3d lower bitrates all around 2025-11-29 02:17:08 -05:00
legop3 d8c8b7f727 guh 2025-11-29 01:30:39 -05:00
legop3 620a01280a ugh 2025-11-28 05:58:16 -05:00
legop3 16bf8e880e uuhdgd 2025-11-28 05:54:48 -05:00
legop3 7a95aabb8d kjdsah 2025-11-28 05:46:16 -05:00
legop3 aec83de635 ugh 2025-11-28 05:43:38 -05:00
legop3 30ada80b0b uuuaghhhfj 2025-11-28 05:38:45 -05:00
legop3 6def1d8cfc beoke 2025-11-28 05:33:21 -05:00
legop3 24f7066f6d ugh 2025-11-28 05:31:56 -05:00
legop3 bbf0da86f2 puh 2025-11-28 05:27:06 -05:00
legop3 d70cf3d216 lets hope this works 2025-11-28 05:11:46 -05:00
legop3 eb27e9e620 wuuuhufd 2025-11-28 04:49:04 -05:00
legop3 29facb35f3 beoke 2025-11-28 04:39:04 -05:00
legop3 65975124f4 mhmm 2025-11-28 04:26:04 -05:00
legop3 d1dc3e470f ill try it 2025-11-28 04:21:29 -05:00
legop3 96428938b9 ugh 2025-11-28 04:09:16 -05:00
legop3 ddfe68699b nopeus 2025-11-28 04:05:29 -05:00
legop3 852af31e10 this isnt gonna work.. 2025-11-28 04:02:40 -05:00
legop3 b70c47a402 aac... 2025-11-28 03:37:14 -05:00
legop3 65b3bbdfd8 please.., 2025-11-28 03:15:46 -05:00
legop3 5dede62b0e fifofofi 2025-11-28 03:10:08 -05:00
legop3 b8a8c412ca build 2025-11-28 02:51:51 -05:00
legop3 0cfac796d8 big shot 2025-11-28 02:29:47 -05:00
legop3 5debdbed94 update battery manager 2025-11-28 02:16:00 -05:00
legop3 b3d6830814 uuhgjkf 2025-11-28 02:06:43 -05:00
legop3 cfdaf0e11f correct mic params? 2025-11-28 01:58:31 -05:00
legop3 2f969ff05d uihkgkfkj 2025-11-28 01:51:57 -05:00
legop3 8be04781a5 wuhguh 2025-11-28 01:48:32 -05:00
legop3 e0259fe20c guhw 2025-11-28 01:44:05 -05:00
legop3 8453660872 wuh 2025-11-28 01:37:47 -05:00
legop3 b0d343109b raw pcm hopefully?? 2025-11-28 01:33:26 -05:00
legop3 ab8d327d18 mu-law 2025-11-28 01:26:45 -05:00
legop3 1552bcca70 stop transcoding audio please 2025-11-28 01:19:17 -05:00
legop3 978e05e77f remove audio filter 2025-11-28 01:07:20 -05:00
legop3 f40ab91c54 ugh 2025-11-28 01:02:05 -05:00
legop3 627e948c59 creature 2025-11-28 00:58:41 -05:00
legop3 1daf5d7d03 good 2025-11-28 00:51:36 -05:00
legop3 9e089b6e4d trying a new method 2025-11-28 00:37:54 -05:00
legop3 52a5b9bab4 plan... ., 2025-11-27 23:53:05 -05:00
legop3 08988962c1 smaller default video bitrate 2025-11-27 23:20:33 -05:00
legop3 5f5591ca42 remove everything related to rover mic while keeping tts 2025-11-27 23:15:21 -05:00
legop3 509dbd870a gh 2025-11-27 23:02:39 -05:00
legop3 f0732a2a12 iuysug 2025-11-27 22:58:23 -05:00
legop3 fb7b0be305 auuuughh 2025-11-27 22:56:02 -05:00
legop3 f9b95c078a debug doesnt exist 2025-11-27 22:52:21 -05:00
legop3 afa2a8a7e5 oh oops 2025-11-27 22:47:23 -05:00
legop3 5a1ff61bc0 lighter audio encoding 2025-11-27 22:27:51 -05:00
legop3 c141fd151a uhg 2025-11-27 22:22:05 -05:00
legop3 97c7098e14 ooooopuusss 2025-11-27 20:58:01 -05:00
legop3 a73c6dfd6b opus!! 2025-11-27 20:49:18 -05:00
legop3 1b1c9d1699 raaah 2025-11-27 20:22:13 -05:00
legop3 6d6bfaaa7f forgot to build again 2025-11-27 20:16:44 -05:00
legop3 d57bd74132 aac encodings 2025-11-27 20:16:33 -05:00
legop3 365d059b0c kjkjdsa 2025-11-27 20:12:14 -05:00
legop3 d5c6b6157c uhfd 2025-11-27 20:00:59 -05:00
legop3 7253e8af2e kjhfiuy 2025-11-27 19:58:07 -05:00
legop3 0067a50cb0 are you suuure 2025-11-27 19:54:24 -05:00
legop3 8f53faec56 forgot to pi build 2025-11-27 19:49:39 -05:00
legop3 a14062909b ugh 2025-11-27 19:49:10 -05:00
legop3 acec70a69c uuuaahhg 2025-11-27 19:42:18 -05:00
legop3 470d1f930d fine 2025-11-27 19:35:07 -05:00
legop3 63f788c16e guh..,ugh 2025-11-27 19:21:25 -05:00
legop3 6eac64beab raaahhh 2025-11-27 19:06:36 -05:00
legop3 37d5841d1c ACTUALLY replace the video env file 2025-11-27 19:01:47 -05:00
legop3 1ec37a43f6 target the hat instead of 0,0 2025-11-27 18:47:03 -05:00
legop3 022249950e make sure input device is actually 0,0 2025-11-27 18:38:09 -05:00
legop3 8cb42dee36 set default to 0,0 2025-11-27 18:16:54 -05:00
legop3 a588df0f0d forgot to pi-build 2025-11-27 17:58:00 -05:00
legop3 99cf12c2b5 ohhhhh..duh 2025-11-27 17:57:37 -05:00
legop3 6d607e697d guh 2025-11-27 17:40:01 -05:00
legop3 bc0aa86381 ugh 2025-11-27 17:31:15 -05:00
legop3 e2a4d2f3e5 make sure the mic is the default hopefully 2025-11-27 16:51:19 -05:00
legop3 9289fb3467 first pass of tts and audio stuff 2025-11-27 16:26:21 -05:00
legop3 871e04a8ba planning audio on pi 2025-11-27 15:21:10 -05:00
legop3 63871ccd17 fix discord bot pings and some of the session spam 2025-11-27 02:01:31 -05:00
legop3 6c43bcbf8d trying to fix spectator page chat 2025-11-27 00:09:07 -05:00
legop3 959812300b don't announce first mode change 2025-11-26 23:35:58 -05:00
legop3 23858b1b6a unlock on waiting and ping on unlock 2025-11-26 23:29:24 -05:00
legop3 0e391ef26d swtich to skipping only once at the beginning 2025-11-25 02:23:54 -05:00
legop3 4364c0eb07 turn idle skipses 2025-11-25 02:15:19 -05:00
legop3 22c4c7110d let them chat! (spectators) 2025-11-25 02:04:25 -05:00
legop3 c7380e30fd make sensors even slower 2025-11-24 18:32:06 -05:00
legop3 c2968a7a33 room camera service for ps3 eye on a pi 3 2025-11-24 17:59:48 -05:00
legop3 3423718ac1 walls doors and airlocks 2025-11-24 13:58:27 -05:00
legop3 ad41a99d10 room camera service for server 2025-11-24 13:51:54 -05:00
legop3 65160e8c14 finally fix 2025-11-24 12:44:34 -05:00
legop3 d2e05f84b2 even smaller sidebar 2025-11-24 12:38:48 -05:00
legop3 f7ce5cefd5 smaller spectator sidebar 2025-11-24 12:37:01 -05:00
legop3 6b017f917e 2 column rover grid 2025-11-24 12:34:56 -05:00
legop3 ba99d41972 new spectator layout 2025-11-24 12:33:10 -05:00
legop3 126d8fbab9 make the spectator videos a little bigger 2025-11-24 04:21:11 -05:00
legop3 0e14445685 finally fixed the scrolling issues! 2025-11-24 04:11:34 -05:00
legop3 4a3b91eeab uuihiufgkkjf 2025-11-24 03:58:55 -05:00
legop3 76200dcd1f janky... but if it works? 2025-11-24 03:56:53 -05:00
legop3 448c2e0239 oops 2025-11-24 03:45:22 -05:00
legop3 a595d793d6 forgot to edit max h 2025-11-24 03:44:14 -05:00
legop3 82f1b71c26 finally? 2025-11-24 03:43:36 -05:00
legop3 e6fec6d9bf iuhgfh 2025-11-24 03:35:30 -05:00
legop3 7893814d03 kjfehkds 2025-11-24 03:31:48 -05:00
legop3 c11ffc750d aaahghg 2025-11-24 03:29:21 -05:00
legop3 2f9349fb40 h48 2025-11-24 03:27:35 -05:00
legop3 7bbb039da1 wuhg 2025-11-24 03:23:22 -05:00
legop3 f80d2445b0 wuhw 2025-11-24 03:20:06 -05:00
legop3 a4a3016c05 give up lol 2025-11-24 03:11:23 -05:00
legop3 9f2b1e423f stupid 2025-11-24 03:09:21 -05:00
legop3 627ca21a56 stupid 2025-11-24 03:07:18 -05:00
legop3 1e788be509 wuhm 2025-11-24 03:04:34 -05:00
legop3 00f31efc3d uhmm.. 2025-11-24 03:00:28 -05:00
legop3 0e80bda371 fix max heights and stuff 2025-11-24 02:57:08 -05:00
legop3 03ef441f1b fix chat getting toooo talll 2025-11-24 02:50:17 -05:00
legop3 59021281e9 fix spectator page, add discord ping filtering 2025-11-24 02:47:21 -05:00
legop3 be1b345fbb oopsie 2025-11-24 00:57:01 -05:00
legop3 9ba7f8fd7a spectator updates 2025-11-24 00:40:11 -05:00
legop3 c1c78585ba spectator UI rebuild 2025-11-24 00:33:36 -05:00
legop3 f5f183e7ce Update video-publisher.sh AWB settings
Changed AWB setting from 'greyworld' to 'custom' with specific gains.
2025-11-23 03:39:29 -05:00
legop3 df175f7198 Add AWB option to video publisher script 2025-11-23 03:35:42 -05:00
legop3 48ac39336c guh 2025-11-22 22:56:41 -05:00
legop3 724df8c59e guh 2025-11-22 22:54:50 -05:00
legop3 60a19494de ugh 2025-11-22 22:53:01 -05:00
legop3 6c4e2b4fdb try different flag flip flag 2025-11-22 22:49:36 -05:00
legop3 74bbd19d85 try different flip flags 2025-11-22 22:43:32 -05:00
legop3 81ada96450 try something ELSE? 2025-11-22 22:40:11 -05:00
legop3 0e44f1ac08 try a different way..? 2025-11-22 22:33:23 -05:00
legop3 4ff6e8c6cd flip camera 2025-11-22 22:29:14 -05:00
legop3 56dede2679 spectator page planning 2025-11-22 20:39:11 -05:00
legop3 a9f2d62d30 add turn alerts client-side 2025-11-22 20:26:21 -05:00
legop3 988554dc5f fix tap and holding buttons 2025-11-22 20:18:01 -05:00
legop3 86b4c9a18c update favicon and such 2025-11-22 20:01:40 -05:00
legop3 fd4f96899a add fullscreen prompt and STUPID apple pwa stuff. 2025-11-22 19:38:42 -05:00
legop3 930f411549 fixing up mobile layouts 2025-11-22 19:10:50 -05:00
legop3 3be9e17ecc add discord invite button in UI 2025-11-22 15:38:44 -05:00
legop3 d1d5ad91bd add discord invite to session 2025-11-22 14:51:34 -05:00
legop3 2d07a3ca4c add turns queue to user list panel 2025-11-22 13:37:30 -05:00
legop3 ef8d1f021b oops 2025-11-22 13:15:33 -05:00
legop3 d222f022aa wuh 2025-11-22 04:03:47 -05:00
legop3 84905544a3 puh 2025-11-22 04:00:26 -05:00
legop3 751bf80338 switch auto lights on to drive macro 2025-11-22 03:58:28 -05:00
legop3 4c3569c001 granpa!! (auto lights on) 2025-11-22 03:54:31 -05:00
legop3 b582617535 update nickname on reconnect 2025-11-22 03:17:20 -05:00
legop3 7b017eb7f7 change discord bot prefix 2025-11-22 03:12:02 -05:00
legop3 3b56427552 fixupx 2025-11-22 03:06:52 -05:00
legop3 d95ba09e00 youe 2025-11-22 02:53:55 -05:00
legop3 a26772055a hopefully fixed discord dock / undock alerts? 2025-11-22 02:44:10 -05:00
legop3 042f653798 battery manager / discord bot fix. add discord tag in chat. 2025-11-22 02:30:51 -05:00
legop3 62075df80d discord bot 2025-11-22 02:05:49 -05:00
legop3 f2a0670861 discord bot planning 2025-11-21 23:20:53 -05:00
legop3 b0ca95807c move UI stuff around, add message sound 2025-11-21 04:55:30 -05:00
legop3 2baecc90ff build new chat frontend 2025-11-21 04:35:48 -05:00
legop3 7dde0d0599 chat, nicknames, user list. 2025-11-21 04:32:49 -05:00
legop3 3d5df5938d planning nicknames and chat and 2025-11-21 03:53:25 -05:00
legop3 746327553e planning nicknames system 2025-11-21 03:27:37 -05:00
legop3 fb748e78e8 try to block admins from home assistant 2025-11-21 03:14:25 -05:00
legop3 23e8c84fa3 change buttons 2025-11-21 02:00:43 -05:00
legop3 c52aa7d998 ignore package-lock... 2025-11-21 01:47:44 -05:00
legop3 6473b076d5 fix home assistant lights 2025-11-21 01:45:29 -05:00
legop3 e2a3d78bc0 home assistant lights and other tweaks 2025-11-21 01:38:53 -05:00
legop3 e514b1b9e1 unlock roomba only on waiting AND full 2025-11-20 22:26:14 -05:00
legop3 991645c957 fix up driver video component 2025-11-20 22:14:36 -05:00
legop3 ff094c2a2b lock on charge, not on dock 2025-11-20 22:00:51 -05:00
legop3 efdef8ff1c sensor rate limit on pi 2025-11-20 21:13:55 -05:00
legop3 aa048d7bdc adjust some styling. move the session snapshot to admin settings 2025-11-20 18:31:28 -05:00
legop3 ac22aa3f84 battery warning overlay 2025-11-20 17:06:08 -05:00
legop3 3b99ceecb2 minimization 2025-11-20 16:56:27 -05:00
legop3 3af8702d48 fix video auth stuff 2025-11-20 16:47:56 -05:00
legop3 ac634a4ab2 add room cameras to driver tab 2025-11-20 16:35:23 -05:00
legop3 da494043c8 unmute forever 2025-11-20 16:31:51 -05:00
legop3 220535e72a vertical room cameras 2025-11-20 16:21:18 -05:00
legop3 d816345560 fix reconnectings 2025-11-20 15:59:08 -05:00
legop3 e05f258fb3 room camera use its own video players 2025-11-20 15:57:00 -05:00
legop3 a92294dba8 forgot to build... 2025-11-20 15:28:34 -05:00
legop3 bb8540828e oops 2025-11-20 15:27:24 -05:00
legop3 ea59a196bb ugh 2025-11-20 15:21:13 -05:00
legop3 bfdfa33b62 sensor stream throttle and new roster panel 2025-11-20 14:55:59 -05:00
legop3 ff0bf30eac servo fix 2025-11-20 14:12:06 -05:00
legop3 400054ae58 AAAAH 2025-11-20 13:56:19 -05:00
legop3 233dd993e2 logger fix 2025-11-20 13:52:38 -05:00
legop3 d09c63ca7d battery manager progress 2025-11-20 13:48:26 -05:00
legop3 a4cc514b9a sync every 5 seconds 2025-11-19 17:03:46 -05:00
legop3 d49ec34f0a rover manager improvements and event bus 2025-11-19 16:49:53 -05:00
legop3 598c0404a2 planning 2025-11-19 16:23:15 -05:00
legop3 fc391a9d8b documentation 2025-11-19 14:30:40 -05:00
legop3 cf4e754aed local env var for dev 2025-11-19 13:55:04 -05:00
legop3 1da2729251 wuh 2025-11-18 22:28:30 -05:00
legop3 ed233c1cb6 guh 2025-11-18 22:24:30 -05:00
legop3 ed7804e0f3 oops 2025-11-18 20:56:51 -05:00
legop3 a1bd8eea96 guh 2025-11-18 20:54:58 -05:00
legop3 288309055a set default mode to admin 2025-11-18 20:39:27 -05:00
legop3 7515510880 guhbuh 2025-11-18 20:02:47 -05:00
legop3 1a38ed7b73 guh 2025-11-18 19:17:10 -05:00
legop3 b4171eddc1 update stun servers and stuff 2025-11-18 18:33:40 -05:00
legop3 bc3d216915 log 2025-11-18 17:11:05 -05:00
legop3 4cbcdef9c2 guh 2025-11-18 17:07:14 -05:00
legop3 978df8fb62 guh 2025-11-18 17:03:48 -05:00
legop3 d812a1fc6c guh 2025-11-18 16:56:55 -05:00
legop3 080c9c8fce guh 2025-11-18 16:47:37 -05:00
legop3 fb5b166a37 coloooors 2025-11-18 15:36:56 -05:00
legop3 457ad42565 guh 2025-11-18 14:22:47 -05:00
legop3 8ca1acb172 g 2025-11-18 14:15:27 -05:00
legop3 b1bd1513b5 guh 2025-11-18 14:14:23 -05:00
legop3 63d796dcd4 guh 2025-11-18 14:13:37 -05:00
legop3 8e520a6368 guhwuh 2025-11-18 14:13:06 -05:00
legop3 e037096914 guhwhu 2025-11-18 14:03:43 -05:00
legop3 543337ac8b guh 2025-11-18 13:57:39 -05:00
legop3 d60d765008 guh 2025-11-18 13:53:21 -05:00
legop3 f32c2549d7 hectare 2025-11-18 13:49:31 -05:00
legop3 ff808476a2 guhwuh 2025-11-18 13:41:36 -05:00
legop3 cc389acbb9 whayy 2025-11-18 13:38:27 -05:00
legop3 5fe68d6027 guhwhu 2025-11-18 13:01:57 -05:00
legop3 3151419b92 guh wuh 2025-11-18 04:33:04 -05:00
legop3 22c5858ab6 tabguhwuh 2025-11-18 04:29:09 -05:00
legop3 519d2e2836 guhguh 2025-11-18 04:10:55 -05:00
legop3 189eb4c309 guh 2025-11-18 03:50:30 -05:00
legop3 481c0e1bcd guhpad 2025-11-18 03:45:32 -05:00
legop3 6efa1b268f gumap 2025-11-18 03:32:18 -05:00
legop3 e7793c8ef2 guhpad 2025-11-18 03:21:13 -05:00
legop3 98b61a7845 guh 2025-11-18 03:15:30 -05:00
legop3 5c66bbd809 teraria 2025-11-18 00:09:48 -05:00
legop3 7390d5c1ac factorio 2025-11-18 00:00:14 -05:00
legop3 4c5dfa093c GONE. 2025-11-17 23:59:08 -05:00
legop3 ffa4189763 controls 2025-11-17 23:48:58 -05:00
legop3 d0ea99bc4c guhw 2025-11-17 23:40:54 -05:00
legop3 baf7e2ea44 guh 2025-11-17 23:28:22 -05:00
legop3 10e78757b9 uuuaaahhg 2025-11-17 23:24:49 -05:00
legop3 a038bf775a guh 2025-11-17 23:22:15 -05:00
legop3 f8d01a810c guh 2025-11-17 23:15:54 -05:00
legop3 9c45075cd1 guh wh 2025-11-17 22:58:02 -05:00
legop3 12b5e18f0a guh 2025-11-17 21:36:09 -05:00
legop3 dc6a4b3c3a guh 2025-11-17 21:18:43 -05:00
legop3 2303fd3c7b guh 2025-11-17 20:42:15 -05:00
legop3 3836bd3cc1 guh 2025-11-17 20:23:56 -05:00
legop3 8c40c08b57 guhwuh 2025-11-17 19:40:50 -05:00
legop3 0c83df7933 pi guh 2025-11-17 19:32:26 -05:00
legop3 c3ea5f02e2 guh 2025-11-17 19:25:28 -05:00
legop3 9a6c54ce7c ugh 2025-11-17 19:24:11 -05:00
legop3 ae619b8faf guh 2025-11-17 19:22:06 -05:00
legop3 88161468e8 guh 2025-11-17 19:14:10 -05:00
legop3 a580563be5 guh 2025-11-17 19:13:02 -05:00
legop3 95cfe4557c guh server 2025-11-17 19:06:35 -05:00
legop3 5ac94f7110 guh 2025-11-17 18:59:55 -05:00
legop3 090fffe356 guh 2025-11-17 18:48:01 -05:00
legop3 2a942ff55c guh 2025-11-17 18:39:21 -05:00
legop3 9b552fb857 pi side servo stuff 2025-11-17 18:18:46 -05:00
legop3 ab15ce7e17 are you having a stroke 2025-11-17 17:12:49 -05:00
legop3 4bd9e25fc4 beginnings of the servo 2025-11-17 17:02:09 -05:00
legop3 3bd723202c Merge pull request #2 from legop3/rpi-zero
Rpi zero
2025-11-17 16:26:05 -05:00
148 changed files with 8551 additions and 1071 deletions
+1
View File
@@ -11,3 +11,4 @@ config.h
robots.json
roverd-dummy
server/config.yaml
package-lock.json
+3 -1
View File
@@ -79,7 +79,9 @@ The script must be executed via `sudo` from the user that owns the repo. It will
Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically.
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (`http://192.168.0.86:8889`). The client automatically requests `http://<base>/<rover-id>/whep`, so you never have to embed IDs or path templates. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer.
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (set it to the URL you expose publicly, e.g. `https://rover.otter.land/video`). If your proxy cant 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
Vendored
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -60,7 +60,7 @@ Flags:
| `-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 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
@@ -71,7 +71,7 @@ Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
```
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`.
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`; keep proxy-only path prefixes out of this URL so every rover keeps the same simple stream name.
2. Install the systemd unit:
```bash
+26
View File
@@ -0,0 +1,26 @@
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
options snd_rpi_googlevoicehat_soundcard index=0
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
pcm.softvol {
type softvol
slave.pcm "plughw:0,0"
control {
name "SoftMaster"
card 0
}
min_dB -51.0
max_dB 0.0
}
# Defaults: playback through softvol, capture raw on the HAT
pcm.!default {
type asym
playback.pcm "softvol"
capture.pcm "hw:0,0"
}
ctl.!default {
type hw
card 0
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
set +H
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
AUDIO_ENABLE="${AUDIO_ENABLE:-0}"
if [[ "${AUDIO_ENABLE}" -ne 1 ]]; then
echo "Audio capture disabled; skipping audio-only publisher" >&2
exit 0
fi
: "${AUDIO_PUBLISH_URL:?AUDIO_PUBLISH_URL not set in ${ENV_FILE}}"
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:0,0}"
AUDIO_RATE="${AUDIO_RATE:-48000}"
AUDIO_CHANNELS="${AUDIO_CHANNELS:-2}"
if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN"
elif command -v ffmpeg >/dev/null 2>&1; then
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
else
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
exit 1
fi
run_pipeline() {
"${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-rtbufsize 0 \
-thread_queue_size 4096 \
-f s32le \
-ar "${AUDIO_RATE}" \
-ac "${AUDIO_CHANNELS}" \
-i <(arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw) \
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=30dB" \
-c:a libopus \
-b:a 24000 \
-ar:a 16000 \
-ac:a 1 \
-application lowdelay \
-frame_duration 20 \
-compression_level 0 \
-f mpegts \
"${AUDIO_PUBLISH_URL}"
}
while true; do
if run_pipeline; then
exit 0
fi
echo "Audio-only publisher exited, restarting in 2s..." >&2
sleep 2
done
+7 -1
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Disable history expansion so PUBLISH_URL values with "!" are safe when sourcing env files.
set +H
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
@@ -10,13 +12,14 @@ fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
VIDEO_FPS="${VIDEO_FPS:-30}"
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
FLIP_ARGS=(--rotation 180)
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
@@ -44,12 +47,15 @@ run_pipeline() {
--timeout 0 \
--width "${VIDEO_WIDTH}" \
--height "${VIDEO_HEIGHT}" \
"${FLIP_ARGS[@]}" \
--framerate "${VIDEO_FPS}" \
--bitrate "${VIDEO_BITRATE}" \
--codec h264 \
--profile baseline \
--denoise cdn_off \
--nopreview \
--awb custom \
--awbgains 1.5,1.8 \
--output - \
| "${FFMPEG_BIN_PATH}" \
-hide_banner \
+124 -9
View File
@@ -94,7 +94,39 @@ install_video_deps() {
apt-get install -y --no-install-recommends libcamera-apps ffmpeg
}
ensure_user roverd "dialout,gpio,video,render"
find_boot_config() {
if [[ -f /boot/firmware/config.txt ]]; then
printf "/boot/firmware/config.txt"
return 0
fi
if [[ -f /boot/config.txt ]]; then
printf "/boot/config.txt"
return 0
fi
return 1
}
ensure_pwm_overlay() {
local boot_config
if ! boot_config="$(find_boot_config)"; then
log "WARNING: unable to locate /boot config.txt; please ensure dtoverlay=pwm-2chan is added manually for servo support"
return
fi
if grep -Eq '^\s*dtoverlay=pwm(-2chan)?' "$boot_config"; then
log "PWM overlay already present in $boot_config"
return
fi
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
cp "$boot_config" "$backup"
{
echo ""
echo "# Added by roverd installer to expose PWM hardware for camera servo control on GPIO12/13 (leaves GPIO18/19 free for I2S)"
echo "dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4"
} >> "$boot_config"
log "Enabled dtoverlay=pwm-2chan on GPIO12/13 in $boot_config (backup at $backup). Reboot required for changes to apply."
}
ensure_user roverd "dialout,gpio,video,render,audio"
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
log "Installed roverd binary"
@@ -112,33 +144,116 @@ install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
log "Installed roverd systemd unit"
install_video_deps
ensure_pwm_overlay
# Enable Google AIY v1 sound card, ALSA defaults, and TTS engines
install_audio_support() {
local boot_config
if ! boot_config="$(find_boot_config)"; then
log "WARNING: unable to locate /boot config.txt; please enable googlevoicehat-soundcard overlay manually"
else
# Ensure onboard audio is disabled (prevents card index flapping)
if grep -Eq '^\s*dtparam=audio=on\b' "$boot_config"; then
log "Disabling onboard audio (dtparam=audio=on -> off) in $boot_config"
sed -i 's/^\s*dtparam=audio=on\b/# roverd disabled onboard audio\ndtparam=audio=off/' "$boot_config"
fi
if ! grep -Eq '^\s*dtparam=audio=off\b' "$boot_config"; then
log "Adding dtparam=audio=off to $boot_config"
echo "dtparam=audio=off" >> "$boot_config"
fi
if ! grep -Eq '^\s*dtparam=i2s=on\b' "$boot_config"; then
log "Adding dtparam=i2s=on to $boot_config"
echo "dtparam=i2s=on" >> "$boot_config"
fi
if ! grep -Eq '^\s*dtoverlay=googlevoicehat-soundcard\b' "$boot_config"; then
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
cp "$boot_config" "$backup"
{
echo ""
echo "# Added by roverd installer to enable Google AIY v1 sound card"
echo "dtoverlay=googlevoicehat-soundcard"
} >> "$boot_config"
log "Enabled googlevoicehat-soundcard overlay in $boot_config (backup at $backup). Reboot required."
else
log "googlevoicehat-soundcard overlay already present in $boot_config"
fi
fi
if [[ -f pi/asound.conf ]]; then
install -m 0644 pi/asound.conf /etc/asound.conf
log "Installed ALSA config to /etc/asound.conf"
alsa_reload_notice=1
else
log "WARNING: pi/asound.conf missing; skipping ALSA config install"
fi
if [[ "${alsa_reload_notice:-0}" -eq 1 ]]; then
log "ALSA config updated; reboot recommended for overlay + audio changes"
fi
log "Installing TTS/audio packages (flite, espeak)..."
# check for flite and espeak before installing, and then install them if either is missing
if command -v flite >/dev/null 2>&1 && command -v espeak >/dev/null 2>&1; then
log "TTS packages flite and espeak already installed; skipping apt install"
return
fi
apt-get update
apt-get install -y --no-install-recommends flite espeak
}
# Install video publisher assets
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
log "Installed video-publisher helper"
install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service
log "Installed video-publisher systemd unit"
# Install audio-only publisher assets
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
log "Installed audio-only publisher helper + systemd unit"
install -d -o roverd -g roverd /var/lib/roverd
if [[ ! -f /var/lib/roverd/video.env ]]; then
cat > /var/lib/roverd/video.env <<'ENV'
cat > /var/lib/roverd/video.env <<'ENV'
# Managed by roverd; placeholder values will be overwritten at runtime.
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
VIDEO_WIDTH=1280
VIDEO_HEIGHT=720
VIDEO_FPS=30
VIDEO_BITRATE=3000000
VIDEO_BITRATE=2000000
AUDIO_ENABLE=0
AUDIO_DEVICE=hw:0,0
AUDIO_RATE=48000
AUDIO_CHANNELS=2
ENV
chown roverd:roverd /var/lib/roverd/video.env
chmod 0640 /var/lib/roverd/video.env
chown roverd:roverd /var/lib/roverd/video.env
chmod 0640 /var/lib/roverd/video.env
# Create persistent audio FIFO for capture -> publisher
FIFO_PATH="/var/lib/roverd/audio.pcm"
if [[ -p "$FIFO_PATH" ]]; then
chown roverd:audio "$FIFO_PATH"
chmod 0660 "$FIFO_PATH"
else
rm -f "$FIFO_PATH"
mkfifo "$FIFO_PATH"
chown roverd:audio "$FIFO_PATH"
chmod 0660 "$FIFO_PATH"
fi
# Ensure ALSA config is in place for rovermic device
install -m 0644 pi/asound.conf /etc/asound.conf
log "Installed ALSA config (/etc/asound.conf)"
install_audio_support
systemctl daemon-reload
systemctl enable roverd.service
systemctl enable video-publisher.service
systemctl enable audio-only-publisher.service
if [[ $CONFIG_EXISTS -eq 1 ]]; then
systemctl restart roverd.service
systemctl restart video-publisher.service
log "Restarted roverd + video publisher"
systemctl restart audio-only-publisher.service
log "Restarted roverd + video/audio publishers"
else
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher"
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher"
fi
log "Install complete"
+2 -1
View File
@@ -10,9 +10,10 @@ build:
pi-build:
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
dummy:
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
clean:
rm -f $(BIN_DIR)/roverd
rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier
+143
View File
@@ -0,0 +1,143 @@
//go:build !dummy
package roverd
import (
"fmt"
"log"
"math"
"sync"
rpio "github.com/stianeikeland/go-rpio/v4"
)
type CameraServo struct {
cfg CameraServoConfig
logger *log.Logger
pin rpio.Pin
mu sync.Mutex
currentAngle float64
closed bool
}
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("camera servo disabled")
}
if err := rpio.Open(); err != nil {
return nil, fmt.Errorf("open gpio: %w", err)
}
pin := rpio.Pin(cfg.Pin)
pin.Mode(rpio.Pwm)
targetClock := cfg.FreqHz * cfg.CycleLen
pin.Freq(targetClock)
servo := &CameraServo{
cfg: cfg,
logger: logger,
pin: pin,
}
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
rpio.Close()
return nil, err
}
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle)
return servo, nil
}
func (s *CameraServo) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
rpio.Close()
s.closed = true
}
func (s *CameraServo) SetAngle(angle float64) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.setAngleLocked(angle)
}
func (s *CameraServo) setAngleLocked(angle float64) error {
if s.closed {
return fmt.Errorf("servo closed")
}
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
s.applyPulseLocked(s.angleToPulse(clamped))
s.currentAngle = clamped
return nil
}
func (s *CameraServo) Nudge(delta float64) error {
s.mu.Lock()
defer s.mu.Unlock()
if delta == 0 {
delta = s.cfg.NudgeDegrees
}
target := s.currentAngle + delta
return s.setAngleLocked(target)
}
func (s *CameraServo) SetPulseWidth(micros int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return fmt.Errorf("servo closed")
}
if !s.cfg.AllowRawPulse {
return fmt.Errorf("raw pulse commands disabled")
}
if micros <= 0 {
return fmt.Errorf("pulse width must be > 0")
}
s.applyPulseLocked(micros)
s.currentAngle = s.pulseToAngle(micros)
return nil
}
func (s *CameraServo) CurrentAngle() float64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.currentAngle
}
func (s *CameraServo) applyPulseLocked(micros int) {
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
}
func (s *CameraServo) angleToPulse(angle float64) int {
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
if totalRange == 0 {
return s.cfg.MinPulseUs
}
norm := (angle - s.cfg.MinAngle) / totalRange
norm = math.Max(0, math.Min(1, norm))
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
}
func (s *CameraServo) pulseToAngle(pulse int) float64 {
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
if pulseRange == 0 {
return s.cfg.MinAngle
}
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
norm = math.Max(0, math.Min(1, norm))
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
}
func clampInt(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}
+32
View File
@@ -0,0 +1,32 @@
//go:build dummy
package roverd
import (
"fmt"
"log"
)
type CameraServo struct{}
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
return nil, fmt.Errorf("camera servo not supported in dummy build")
}
func (c *CameraServo) Close() {}
func (c *CameraServo) SetAngle(angle float64) error {
return fmt.Errorf("camera servo disabled")
}
func (c *CameraServo) Nudge(delta float64) error {
return fmt.Errorf("camera servo disabled")
}
func (c *CameraServo) SetPulseWidth(micros int) error {
return fmt.Errorf("camera servo disabled")
}
func (c *CameraServo) CurrentAngle() float64 {
return 0
}
+12 -3
View File
@@ -21,7 +21,7 @@ func main() {
if err != nil {
log.Fatalf("load config: %v", err)
}
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil {
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
log.Fatalf("prepare media env: %v", err)
}
@@ -55,15 +55,24 @@ func main() {
adapter := roverd.NewSerialAdapter(serialPort, logger)
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
if mediaSupervisor != nil {
mediaSupervisor.Start(ctx)
}
var cameraServo *roverd.CameraServo
if cfg.CameraServo.Enabled {
cameraServo, err = roverd.NewCameraServo(cfg.CameraServo, logger)
if err != nil {
logger.Fatalf("init camera servo: %v", err)
}
defer cameraServo.Close()
}
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, logger)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, logger)
retryDelay := time.Second
for ctx.Err() == nil {
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"flag"
"log"
"time"
rpio "github.com/stianeikeland/go-rpio/v4"
)
func main() {
var (
pinNum = flag.Int("pin", 19, "BCM pin connected to the servo signal line")
freqHz = flag.Int("freq", 50, "Servo PWM frequency in Hz")
cycleLen = flag.Int("cycle", 20000, "PWM cycle length (counts per period)")
minPulse = flag.Int("min", 900, "Minimum pulse width in microseconds")
maxPulse = flag.Int("max", 2100, "Maximum pulse width in microseconds")
stepPulse = flag.Int("step", 100, "Pulse width increment in microseconds when sweeping")
sweeps = flag.Int("sweeps", 2, "How many full min→max→min sweeps to perform")
pause = flag.Duration("pause", 150*time.Millisecond, "Delay between pulse adjustments")
holdPulse = flag.Int("hold", 0, "Pulse width to hold before exiting (0 = midpoint of min/max)")
)
flag.Parse()
if *freqHz <= 0 || *cycleLen <= 0 {
log.Fatalf("invalid freq (%d) or cycle (%d)", *freqHz, *cycleLen)
}
if *minPulse <= 0 || *maxPulse <= 0 || *minPulse >= *maxPulse {
log.Fatalf("invalid min/max pulses (%d/%d)", *minPulse, *maxPulse)
}
if *stepPulse <= 0 {
log.Fatalf("step must be > 0 (got %d)", *stepPulse)
}
if *pause <= 0 {
log.Fatalf("pause must be > 0 (got %s)", pause)
}
if *sweeps < 0 {
log.Fatalf("sweeps must be >= 0 (got %d)", *sweeps)
}
if err := rpio.Open(); err != nil {
log.Fatalf("open gpio: %v", err)
}
defer rpio.Close()
pin := rpio.Pin(*pinNum)
pin.Mode(rpio.Pwm)
targetClock := *freqHz * *cycleLen
pin.Freq(targetClock)
log.Printf("Configured PWM pin %d at %d Hz (clock=%d Hz, cycle=%d)", *pinNum, *freqHz, targetClock, *cycleLen)
setPulse := func(us int) {
clamped := clamp(us, *minPulse, *maxPulse)
pin.DutyCycle(uint32(clamped), uint32(*cycleLen))
log.Printf("pulse -> %dµs", clamped)
}
mid := (*minPulse + *maxPulse) / 2
setPulse(mid)
runSweep := func() {
for pulse := *minPulse; pulse <= *maxPulse; pulse += *stepPulse {
setPulse(pulse)
time.Sleep(*pause)
}
for pulse := *maxPulse - *stepPulse; pulse >= *minPulse; pulse -= *stepPulse {
setPulse(pulse)
time.Sleep(*pause)
}
}
for i := 0; i < *sweeps; i++ {
log.Printf("Sweep %d/%d", i+1, *sweeps)
runSweep()
}
finalPulse := *holdPulse
if finalPulse <= 0 {
finalPulse = mid
}
setPulse(finalPulse)
log.Printf("Holding at %dµs", clamp(finalPulse, *minPulse, *maxPulse))
log.Print("Servo verification complete")
}
func clamp(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}
+18
View File
@@ -6,6 +6,8 @@ type helloMessage struct {
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
}
type sensorMessage struct {
@@ -22,6 +24,8 @@ type inboundMessage struct {
Raw string `json:"raw,omitempty"`
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"`
}
type driveDirectPayload struct {
@@ -43,6 +47,20 @@ type mediaCommand struct {
Action string `json:"action"`
}
type servoPayload struct {
Angle *float64 `json:"angle,omitempty"`
Nudge *float64 `json:"nudge,omitempty"`
PulseUs *int `json:"pulseUs,omitempty"`
}
type ttsPayload struct {
Text string `json:"text"`
Engine string `json:"engine,omitempty"`
Voice string `json:"voice,omitempty"`
Pitch int `json:"pitch,omitempty"`
Speak bool `json:"speak,omitempty"`
}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+131 -7
View File
@@ -53,11 +53,26 @@ type BatteryConfig struct {
Urgent int `yaml:"urgent"`
}
type AudioConfig struct {
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
Channels int `yaml:"channels" json:"channels,omitempty"`
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
}
type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
PublishPort int `yaml:"publishPort" json:"-"`
Manage bool `yaml:"manage"`
ManageAudio bool `yaml:"manageAudio"`
Service string `yaml:"service"`
AudioService string `yaml:"audioService"`
HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"`
VideoWidth int `yaml:"videoWidth" json:"-"`
@@ -66,6 +81,20 @@ type MediaConfig struct {
VideoBitrate int `yaml:"videoBitrate" json:"-"`
}
type CameraServoConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Pin int `yaml:"pin" json:"pin"`
FreqHz int `yaml:"freqHz" json:"freqHz"`
CycleLen int `yaml:"cycleLen" json:"cycleLen"`
MinPulseUs int `yaml:"minPulseUs" json:"minPulseUs"`
MaxPulseUs int `yaml:"maxPulseUs" json:"maxPulseUs"`
MinAngle float64 `yaml:"minAngle" json:"minAngle"`
MaxAngle float64 `yaml:"maxAngle" json:"maxAngle"`
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
}
type Config struct {
Name string `yaml:"name"`
ServerURL string `yaml:"serverUrl"`
@@ -74,6 +103,8 @@ type Config struct {
Battery BatteryConfig `yaml:"battery"`
MaxWheelMMs int `yaml:"maxWheelSpeed"`
Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"`
}
func LoadConfig(path string) (*Config, error) {
@@ -84,7 +115,7 @@ func LoadConfig(path string) (*Config, error) {
cfg := Config{
MaxWheelMMs: 500,
BRC: BRCConfig{
GPIOPin: -1,
GPIOPin: 4,
GPIOChip: "gpiochip0",
PulseEvery: Duration{
Duration: time.Minute,
@@ -99,7 +130,29 @@ func LoadConfig(path string) (*Config, error) {
VideoWidth: 1280,
VideoHeight: 720,
VideoFPS: 30,
VideoBitrate: 3000000,
VideoBitrate: 2000000,
},
CameraServo: CameraServoConfig{
Pin: 12,
FreqHz: 50,
CycleLen: 20000,
MinPulseUs: 900,
MaxPulseUs: 2100,
MinAngle: -15,
MaxAngle: 30,
HomeAngle: 0,
NudgeDegrees: 2,
},
Audio: AudioConfig{
CaptureEnabled: false,
CaptureDevice: "rovermic",
SampleRate: 48000,
Channels: 2,
Bitrate: 24000,
TTSEnabled: false,
DefaultEngine: "flite",
DefaultVoice: "rms",
DefaultPitch: 50,
},
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
@@ -151,12 +204,83 @@ func LoadConfig(path string) (*Config, error) {
}
cfg.Media.PublishURL = derived
}
if cfg.Media.AudioPublishURL == "" {
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
if err != nil {
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
}
cfg.Media.AudioPublishURL = derived
}
if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err)
}
validateAudioConfig(&cfg.Audio)
return &cfg, nil
}
func derivePublishURL(serverURL, roverName string, port int) (string, error) {
if roverName == "" {
return "", errors.New("missing rover name for publishUrl")
func validateServoConfig(cfg *CameraServoConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.Pin <= 0 {
return errors.New("pin must be > 0")
}
if cfg.FreqHz <= 0 {
return errors.New("freqHz must be > 0")
}
if cfg.CycleLen <= 0 {
return errors.New("cycleLen must be > 0")
}
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 || cfg.MinPulseUs >= cfg.MaxPulseUs {
return errors.New("minPulseUs/maxPulseUs invalid")
}
if cfg.MinAngle >= cfg.MaxAngle {
return errors.New("minAngle must be less than maxAngle")
}
cfg.HomeAngle = clampFloat(cfg.HomeAngle, cfg.MinAngle, cfg.MaxAngle)
if cfg.NudgeDegrees <= 0 {
cfg.NudgeDegrees = 2
}
return nil
}
func clampFloat(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
func validateAudioConfig(cfg *AudioConfig) {
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
cfg.CaptureDevice = "hw:0,0"
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 2
}
if cfg.Bitrate <= 0 {
cfg.Bitrate = 64000
}
if cfg.DefaultEngine == "" {
cfg.DefaultEngine = "flite"
}
if cfg.DefaultVoice == "" {
cfg.DefaultVoice = "rms"
}
if cfg.DefaultPitch <= 0 {
cfg.DefaultPitch = 50
}
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
if streamName == "" {
return "", errors.New("missing stream name for publishUrl")
}
parsed, err := url.Parse(serverURL)
if err != nil {
@@ -169,6 +293,6 @@ func derivePublishURL(serverURL, roverName string, port int) (string, error) {
if port <= 0 {
port = 9000
}
streamName := url.PathEscape(roverName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316", host, port, streamName), nil
escaped := url.PathEscape(streamName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped), nil
}
+1
View File
@@ -3,6 +3,7 @@ module multiroombarover/pi/roverd
go 1.25.4
require (
github.com/stianeikeland/go-rpio/v4 v4.6.0
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
github.com/warthog618/go-gpiocdev v0.9.1
gopkg.in/yaml.v3 v3.0.1
+2
View File
@@ -4,6 +4,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stianeikeland/go-rpio/v4 v4.6.0 h1:eAJgtw3jTtvn/CqwbC82ntcS+dtzUTgo5qlZKe677EY=
github.com/stianeikeland/go-rpio/v4 v4.6.0/go.mod h1:A3GvHxC1Om5zaId+HqB3HKqx4K/AqeckxB7qRjxMK7o=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
+35 -8
View File
@@ -9,24 +9,51 @@ import (
const publisherEnvPath = "/var/lib/roverd/video.env"
func UpdatePublisherEnv(cfg MediaConfig) error {
if cfg.PublishURL == "" {
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.PublishURL == "" {
return fmt.Errorf("media publishUrl missing")
}
if cfg.VideoWidth <= 0 || cfg.VideoHeight <= 0 || cfg.VideoFPS <= 0 || cfg.VideoBitrate <= 0 {
if media.AudioPublishURL == "" && audio.CaptureEnabled {
return fmt.Errorf("audio publishUrl missing")
}
if media.VideoWidth <= 0 || media.VideoHeight <= 0 || media.VideoFPS <= 0 || media.VideoBitrate <= 0 {
return fmt.Errorf("invalid media dimensions/bitrate")
}
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", cfg.PublishURL)
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth)
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS)
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate)
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
if audio.CaptureEnabled && media.AudioPublishURL != "" {
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
}
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
audioDevice := audio.CaptureDevice
if audioDevice == "" || audioDevice == "rovermic" {
audioDevice = "hw:0,0"
}
if audio.SampleRate <= 0 {
audio.SampleRate = 48000
}
if audio.Channels <= 0 {
audio.Channels = 2
}
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
return err
}
return nil
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+6 -4
View File
@@ -13,13 +13,14 @@ import (
type MediaSupervisor struct {
cfg MediaConfig
audio AudioConfig
logger *log.Logger
client *http.Client
checkInterval time.Duration
}
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
if err := UpdatePublisherEnv(cfg); err != nil {
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
if err := UpdatePublisherEnv(cfg, audio); err != nil {
logger.Printf("media supervisor: update env failed: %v", err)
}
if !cfg.Manage || cfg.Service == "" {
@@ -35,6 +36,7 @@ func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
}
return &MediaSupervisor{
cfg: cfg,
audio: audio,
logger: logger,
client: client,
checkInterval: interval,
@@ -45,7 +47,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
if m == nil {
return
}
if err := UpdatePublisherEnv(m.cfg); err != nil {
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
m.logger.Printf("media supervisor: update env failed: %v", err)
}
if m.cfg.HealthURL == "" || m.client == nil {
@@ -76,7 +78,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
if m == nil {
return errors.New("media supervisor disabled")
}
if err := UpdatePublisherEnv(m.cfg); err != nil {
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
return err
}
switch action {
+25 -3
View File
@@ -5,7 +5,7 @@ serial:
device: /dev/ttyAMA0
baud: 115200
brc:
gpioPin: 17
gpioPin: 4
gpioChip: gpiochip0
pulseEvery: 1m
pulseWidth: 1s
@@ -15,13 +15,35 @@ battery:
urgent: 1650
maxWheelSpeed: 350
media:
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
publishPort: 9000
videoWidth: 1280
videoHeight: 720
videoFps: 30
videoBitrate: 3000000
videoBitrate: 2000000
manage: true
service: video-publisher.service
healthUrl: ""
healthInterval: 30s
cameraServo:
enabled: false
pin: 12
freqHz: 50
cycleLen: 20000
minPulseUs: 900
maxPulseUs: 2100
minAngle: -15
maxAngle: 30
homeAngle: 0
nudgeDegrees: 2
allowRawPulse: false
audio:
captureEnabled: false
captureDevice: hw:0,0
sampleRate: 48000
channels: 2
bitrate: 24000
ttsEnabled: false
defaultEngine: flite
defaultVoice: rms
defaultPitch: 50
+13 -1
View File
@@ -5,7 +5,7 @@ serial:
device: /dev/ttyAMA0
baud: 115200
brc:
gpioPin: 17
gpioPin: 4
gpioChip: gpiochip0
pulseEvery: 1m
pulseWidth: 1s
@@ -19,3 +19,15 @@ media:
service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s
cameraServo:
enabled: false
pin: 19
freqHz: 50
cycleLen: 20000
minPulseUs: 900
maxPulseUs: 2100
minAngle: -15
maxAngle: 30
homeAngle: 0
nudgeDegrees: 2
allowRawPulse: false
+8
View File
@@ -14,6 +14,7 @@ import (
const (
sensorHeader = 19
sensorReadTimeout = 150 * time.Millisecond
sensorThrottleMinimum = 250 * time.Millisecond
)
type SensorStreamer struct {
@@ -29,6 +30,7 @@ func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSa
func (s *SensorStreamer) Run(ctx context.Context) {
reader := bufio.NewReader(s.r)
var nextSend time.Time
for {
select {
case <-ctx.Done():
@@ -62,6 +64,12 @@ func (s *SensorStreamer) Run(ctx context.Context) {
continue
}
now := time.Now()
if !nextSend.IsZero() && now.Before(nextSend) {
continue
}
nextSend = now.Add(sensorThrottleMinimum)
select {
case s.rawOut <- frame:
default:
+75
View File
@@ -0,0 +1,75 @@
package roverd
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
if payload == nil {
return fmt.Errorf("tts payload required")
}
if !c.cfg.Audio.TTSEnabled {
return fmt.Errorf("tts disabled on rover")
}
if payload.Speak == false {
return nil
}
text := strings.TrimSpace(payload.Text)
if text == "" {
return fmt.Errorf("tts text required")
}
if len([]rune(text)) > 512 {
text = string([]rune(text)[:512])
}
engine := strings.ToLower(strings.TrimSpace(payload.Engine))
if engine == "" {
engine = strings.ToLower(strings.TrimSpace(c.cfg.Audio.DefaultEngine))
}
if engine == "" {
engine = "flite"
}
voice := strings.TrimSpace(payload.Voice)
if voice == "" {
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
}
pitch := payload.Pitch
if pitch <= 0 {
pitch = c.cfg.Audio.DefaultPitch
}
pitch = clampInt(pitch, 0, 99)
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
var cmd *exec.Cmd
switch engine {
case "espeak", "e":
args := []string{}
if pitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", pitch))
}
args = append(args, text)
cmd = exec.CommandContext(ctx, "espeak", args...)
case "flite", "f":
args := []string{}
if voice != "" {
args = append(args, "-voice", voice)
}
args = append(args, "-t", text)
cmd = exec.CommandContext(ctx, "flite", args...)
default:
return fmt.Errorf("unsupported tts engine: %s", engine)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
}
return nil
}
+26 -1
View File
@@ -17,16 +17,18 @@ type WSClient struct {
sensorFrames <-chan []byte
events <-chan RoverEvent
media *MediaSupervisor
servo *CameraServo
log *log.Logger
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, logger *log.Logger) *WSClient {
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, servo *CameraServo, logger *log.Logger) *WSClient {
return &WSClient{
cfg: cfg,
adapter: adapter,
sensorFrames: frames,
events: events,
media: media,
servo: servo,
log: logger,
}
}
@@ -68,7 +70,10 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio,
}
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg)
}
@@ -141,11 +146,31 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return fmt.Errorf("media supervisor disabled")
}
return c.media.HandleAction(ctx, msg.Media.Action)
case msg.Servo != nil:
if c.servo == nil {
return fmt.Errorf("camera servo disabled")
}
return c.handleServoCommand(msg.Servo)
case msg.TTS != nil:
return c.handleTTSPayload(msg.TTS)
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
}
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
switch {
case payload.Angle != nil:
return c.servo.SetAngle(*payload.Angle)
case payload.Nudge != nil:
return c.servo.Nudge(*payload.Nudge)
case payload.PulseUs != nil:
return c.servo.SetPulseWidth(*payload.PulseUs)
default:
return fmt.Errorf("servo command requires angle, nudge, or pulseUs")
}
}
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
for {
select {
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Rover Audio Publisher (ALSA -> SRT)
After=network-online.target roverd.service
Wants=network-online.target
[Service]
Type=simple
User=roverd
Group=roverd
EnvironmentFile=/var/lib/roverd/video.env
ExecStart=/usr/local/bin/audio-only-publisher
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
-2
View File
@@ -8,8 +8,6 @@ Type=simple
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
Restart=on-failure
RestartSec=5
User=roverd
Group=roverd
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
[Install]
+27
View File
@@ -0,0 +1,27 @@
# general idea
- the pi on each roomba will have a speaker and microphone
- alsa devices
- they are both the default audio device
- you will have to modify roverd, the pi install script, and the server to get this all to work
# adding stuff to installation script
- I am using the google voice AIY v1 kits for audio
- boot config stuff
- enable `dtoverlay=googlevoicehat-soundcard`
- disable `dtparam=audio=on`
- copy asound.conf in pi folder to /etc/asound.conf
# microphone
- add microphone to the SRT publish stream
# speaker
- user's chat messages will be TTS'ed through the speaker on the pi
- either flite with a way to choose the voice
- or espeak where you can choose the pitch
- on the web UI and in the chat API
- add new stuff to chat
- only shows up if you are on a rover
- only shows up if TTS is enabled on that rover
- people can choose between flite or espeak
- if they choose flite, they can choose the voice from the default flite voices (exclude awb and awb_time)
- if they choose espeak, they can choose the pitch. Have a dropdown with increments of 10 from 0 to 99
+27
View File
@@ -0,0 +1,27 @@
# general idea
- uses the rover's reported battery full, warn, and urgent values
- will apply to all rovers individually
- uses the rover locking system
- completely server side
- always use the battery warn value as 0% battery and the full value as 100%
## first: the server-side server-wide event bus
- global server event bus
- used for realtime alerts between modules
- includes a way to tell where the event is coming from
## what will the battery manager do?
- watch each rover's battery charge number (reported in sensors)
- if the number reaches warn, fire an event on the event bus
- the UI will show a warning to users, independently based on sensor data
- once the rover is docked and charging, lock it.
- when the battery is fully charged, unlock it.
## while we're at it...
- add a lock reason to the rover locking system
- add the following to the rover roster in the UI session state:
- battery full #
- battery warn #
- battery urgent #
- in the rover roster UI component, add a display for battery percentage and lock reason.
- make the rover's background red when locked
+9
View File
@@ -0,0 +1,9 @@
# general idea
- IR led on the front of each roomba
- connected to a pin on the pi
- made to be very directional
- each roomba has a different IR code
- use the omni reciever to get a shot
- keep score per user
- use the go alert system
- alert discord with a live score count
+35
View File
@@ -0,0 +1,35 @@
# general idea
- discord bot run by the server
- use discord.js
- don't use discord's slash commands
- listen for commands the old fashioned way
- only admins with IDs set in the server config can use commands
- use nice looking embeds with colors for everything but messages
# features
## admin server management commands
- admins can lock and unlock rovers from discord
- admins can change the server access control mode
## announcements
- the bot has an announcements channel assigned in the server config. It will also have an announcement role ID to optionally ping. it will announce:
- when a rover is locked / unlocked (no ping)
- when the server access mode is changed (ping)
- the bot will have an admin ping role and alert channel. in here will be:
- rover activity (no pings)
- docking
- charging
- stopping charging
- undocking
- rover alerts (ping)
- rover comes online
- rover goes offline
- rover is at warn battery
- rover is at urgent battery
## chat bridge
- bridge between the server chat and discord chat
- use chat events that are on the bus already
+15
View File
@@ -0,0 +1,15 @@
# general idea:
- control on / off switches and lights in home assistant from the web UI
- use this:
- https://www.npmjs.com/package/home-assistant-js-websocket/v/3.1.2
- remember to ignore updates that are of the same states, this library will give you a lot of those
- switches and lights are configured in the server's config file
- give each one a name (no description)
- auto detect a switch type or light type
- deliver list of lights to the UI through the session service
- show realtime on/off status of the switches / lights in the UI
- create a react component for the controls
- it will automatically create a control for each switch / light
## permissions:
- even if someone isnt assigned to a rover, they should be able to control the switches / lights
@@ -18,7 +18,7 @@
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.
- 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**
+53
View File
@@ -0,0 +1,53 @@
# nicknames:
- users will be able to have and set nicknames
- a user's nickname will store in the browser using persistence.js
- the user's nickname should probably just be in socket.data.nickname
- on connection, the web UI will tell the server "this is my nickname"
- completely enforced by the web UI
- replace any place in the web UI that shows a socket ID with the nickname
- create a small react component which can set your nickname and save it
# user list in session data:
- add a list of users to the session data for the web UI
- contains for each user:
- socket ID
- nickname
- the rover that they are driving
- their role (user, admin, lockdown, spectator)
# user list:
- a list of users
- new react component
- uses user list with nicknames from session data
- show their nickname and whether or not they are an admin
# chat:
- server side chat system:
- don't store message history
- emit an event to the event bus for each message
- eventually will be forwarded to discord through a bot
- listen to chat message events on the bus
- eventually the discord bot will also send messages to the server chat
- profanity filter
- spam filter
- repeated words
- keymashing
- etc
- somehow link chat messages to rovers
- in the future rovers will have TTS onboard, and will speak chat messages only from the person driving the rover
- chat on the UI
- new react component
- show messages as they come in:
- time (just like 19:23), sender nickname, rover they are driving, message
- pressing enter on the keyboard will pause rover control, and focus the chat box.
- pressing enter again after typing will send the message
# layout and styling for new UI elements:
- use index.css styles to match the new stuff to the current UI
- just edit the desktop page for now
- layout:
- in the left column, a new row in between video and logs
- 50/50 split between:
- user list and nickname entry
- chat history and chat box
View File
+23
View File
@@ -0,0 +1,23 @@
# general idea
- the pi's microphone needs to be streamed to users
- as part of the rover's video feed
- the pi model is a zero 2 W
- it CANNOT handle transcoding the audio onboard, I have tried.
- the server is super powerful and can handle transcoding
- it is NOT acceptable to add more than 100ms of video latency from glass to glass
- it is NOT acceptable to break video streaming for pis that don't have microphones
### OLD, BAD, but working audio configuration that worked for the microphone
```
-f alsa -guess_layout_max 0 -thread_queue_size 2048 -ac 1 -ar 48000 -i plughw:0,0 \
-map 1:a:0 \
-c:a libopus -b:a 24000 -compression_level 0 -application voip -frame_duration 60 \
-ac:a 1 -ar:a 48000 -af "pan=1c|c0=c0,volume=20dB"
```
- the raspberry pi CANNOT HANDLE AUDIO ENCODING onboard. this is just to show you how to use the mic.
sudo systemctl restart roverd
#sudo systemctl restart audio-capture
sudo systemctl restart video-publisher
sudo cat /var/lib/roverd/video.env
+27
View File
@@ -0,0 +1,27 @@
## raspberry pi
- need to throttle sensor sending, maybe only send one in every 5 packets.
## 3d models for camera:
- https://www.thingiverse.com/thing:2873677
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
- https://www.thingiverse.com/thing:4514531
## spectator page
- completely remake it from scratch
## 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
+69
View File
@@ -0,0 +1,69 @@
# general idea
- a modular control system
- every piece of the UI that uses rover controls will go through this, including onscreen click buttons
- allows for dynamic control labels based on saved settings
- allow a place to assign OI command macros
- like the one that the drive button uses now
- allows realtime responsive control of the rover that you are driving
## some sort of system for the site to save settings per browser
- needs to be extensible
- future things will use it
- expose functions like saveSettings and loadSettings
- other parts of the UI will need to save and load settings using this function
- use cookies
## keyboard controls:
- remappable by new component in settings tab
- key mappings will save
- controls for the keyboard
- driving
- WASD blended for tank steering
- hold backslash to move faster
- hold right shift to move slower
- aux motors
- main brush
- hold O to move it forward at speed 127
- hold L to move it backward at speed -127
- side brush
- hold P to move it forward at speed 127
- hold ; to move it backward at speed -70
- vacuum motor
- hold [ to move it at speed 127
- hold ' to move it at speed 50
- ALL AUX MOTORS
- hold . to move at full speed forward (127)
- camera movement
- hold I to look up
- hold K to look down
- dock and drive hotkeys
- G for drive (use same 3 part macro as the drive button)
- H for dock (seek dock command)
## Mobile controls
- 2 different layouts, already implemented just needs improved.
### mobile landscape
- a video game style layout
- on the left, buttons that you hold to operate the aux motors
- in the middle, is the rover video component.
- on the right, an area for a floating joystick and above is a unified control to see and change the rover's mode (drive or dock)
### mobile portrait
- designed to be driven vertically with 2 hands
- mostly fine already
- rover video at the top
- then below it, is a section with the aux motor buttons on the left, and the floating joystick are on the right.
## Gamepad controls:
- use some sort of react thing that makes it easy to use the web gamepad stuff
- left joystick for movement, right joystick for moving camera up / down
- both fully analog
- right trigger for the main brush motor
- fully analog
- press right bumper to switch it to reverse
- left trigger for the side brush motor
- fully analog
- press left bumper to switch it to reverse
- hold the right face button to run the vacuum motor
- hold the lower face button to run all aux motors forward
- left and right on the Dpad switch between drive and dock modes
+10
View File
@@ -0,0 +1,10 @@
# general idea
- convert room cameras from webrtc h264 streams to 4fps jpegs sent over socket.io
## requirements
- make updated services for server and pi room cams
- no backwards compatability is needed
- room cameras are both 4:3, make sure they show as such in the UI
- remove anything related to the webRTC room cams
- send the jpegs efficiently (as binary)
- make sure the room cams are still authed as they are now
+14
View File
@@ -0,0 +1,14 @@
# general idea
- add support for multiple room cameras through the server to the UI
## what needs to happen on the server:
- add support for a path of room cameras on mediamtx
- /room/<camera_name>
- CANNOT interfere with rover cameras (/<rover_name>)
- needs to use the same auth system as the rover camera
- room cameras will be streamed to the server over SRT
## what needs to happen in the web UI
- users should be able to see all room cameras, even if not assigned to a rover
- automatically add a room camera player for each room camera
- make a component that shows all room cameras
+44
View File
@@ -0,0 +1,44 @@
# spectate system overhaul
- rip out and remake the entire page
- basically keep nothing from it
- right now the spectator page is a mess, and hasn't been updated in a while
- does not work properly, aside from the page being out of date
- the spectate page should use modules from the driver page
- there should be no such thing as a "spectate mode" on the driver page. If someone wants to spectate, they must go to the spectator page.
## rules of the spectator page
- spectators are not allowed to drive
- spectators are not allowed to chat
- spectators are not allowed to have nicknames
- spectators are not allowed to login as admin
- these are the permissions for spectators based on the sever's access control mode
- open
- spectators can spectate
- turns
- spectators can spectate
- admin
- spectators can spectate
- lockdown
- spectators can NOT spectate
## what should be on the spectator page?
- for each rover
- video component
- current driver
- sensor telemetry component
- logs
- room cameras
- online user list
## layout and styling of the spectator page
- use same styling style as the driver page
- feel free to add spectator role checks to hide the features that spectators can't use in certain components.
- mainly designed for a 4:3 monitor
- make use of the vertical space
- a row of columns, one for each rover at the top
- below the row of columns:
- 50/50 split
- user list
- chat
- room cameras
+23
View File
@@ -0,0 +1,23 @@
# general idea
- a bunch of small UI adjustments
- always keep styling consistent to the rest of the page
## everywhere
- add an audio stream status to the rover video panel, only show it if the rover has the separate audio stream
- make the room camera panels save their stack setting per instance of the panel in the web UI's settings storage system
- remove the status bar below room cameras
- put it in the corner of the feed, like the other video panels
- make the styling of the room camera panels match everything else
- when the user list switches to turns, always show the plain user list, but below the turns info
## on the spectator page
- rover video panels
- no more telemetry bar below each rover video
- ovelay the tiny telemetry summary on the left side of the video, with the rest of the HUD elements
- including the battery bar, make it vertical on the right side of the video
- no more title bar above each rover video
- the rover's name is already on the HUD
- add current driver to the HUD
- sidebar
- shrink the logs panel
- add an actual rover list to the top of the sidebar
-17
View File
@@ -1,17 +0,0 @@
## video stream
- video stream needs to always autoplay, even if it cant unmute itself
- currently, it wont play at all unless it can unmute
- video stream currently unloads and reloads based on the tab being active
- just remove this feature. It needs to stay running all the time.
## server sensor decoding
- OI mode is not decoded properly
- says "safe" when in passive, and "unknown" when in full.
## UI and session info
- UI needs a display for the turns mode
## Mobile UI
- need to make it good
- joystick needs to be floating
- aux motor buttons need redone
+28
View File
@@ -0,0 +1,28 @@
# Room camera snapshot service
Lightweight systemd service that serves a JPEG snapshot from any MJPEG-capable webcam (most USB webcams) at 4 fps. The server pulls `http://<host>:8080/snapshot.jpg` for room cams.
## Files
- `room-cam-snapshot.sh` ffmpeg + simple HTTP server wrapper
- `room-cam.service` systemd unit template
## Usage
1) Copy the service into place (adjust path/env as needed):
```bash
sudo cp roomcam-service/room-cam.service /etc/systemd/system/room-cam.service
sudo systemctl daemon-reload
sudo systemctl enable --now room-cam.service
```
2) Override defaults via `Environment=` in the unit or drop-ins:
- `DEVICE=/dev/video0`
- `RESOLUTION=640x480`
- `QUALITY=5` (ffmpeg MJPEG quality; lower is higher quality)
- `PORT=8080`
- `WORKDIR=/run/roomcam`
- `INPUT_FORMAT=mjpeg` (use `bayer_grbg8` for OV534/raw Bayer cams; aliases `GRBG`/`grbg` are accepted)
3) Point the server `roomCameras[].url` to `http://<host>:8080/snapshot.jpg`.
Notes:
- The camera runs at its native MJPEG frame rate; the server polls snapshots at ~4 fps, so no extra filtering is applied here.
To run outside systemd, just execute `./room-cam-snapshot.sh` with any overrides.
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# Configurable via environment
DEVICE="${DEVICE:-/dev/video0}"
RESOLUTION="${RESOLUTION:-640x480}"
QUALITY="${QUALITY:-5}" # ffmpeg MJPEG quality (lower is better)
PORT="${PORT:-8088}"
WORKDIR="${WORKDIR:-/run/roomcam}"
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
mkdir -p "${WORKDIR}"
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
rm -f "${SNAPSHOT_PATH}"
cleanup() {
[[ -n "${FFMPEG_PID:-}" ]] && kill "${FFMPEG_PID}" 2>/dev/null || true
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 0' SIGTERM INT
FFMPEG_INPUT_ARGS=(-f v4l2 -input_format "${INPUT_FORMAT}" -video_size "${RESOLUTION}" -i "${DEVICE}")
FFMPEG_FILTERS=()
if [[ "${INPUT_FORMAT}" == bayer_* ]]; then
# Convert raw Bayer to a JPEG-friendly pixel format.
FFMPEG_FILTERS=(-pix_fmt yuv420p)
fi
/usr/bin/ffmpeg -y \
-loglevel warning -nostats \
"${FFMPEG_INPUT_ARGS[@]}" \
"${FFMPEG_FILTERS[@]}" \
-q:v "${QUALITY}" \
-f image2 -update 1 "${SNAPSHOT_PATH}" &
FFMPEG_PID=$!
/usr/bin/python3 -u -m http.server "${PORT}" --directory "${WORKDIR}" --bind 0.0.0.0 &
HTTP_PID=$!
wait -n "${FFMPEG_PID}" "${HTTP_PID}"
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Room camera snapshot server (MJPEG webcam)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=DEVICE=/dev/video0
Environment=RESOLUTION=640x480
Environment=QUALITY=5
Environment=PORT=8088
Environment=WORKDIR=/run/roomcam
Environment=INPUT_FORMAT=mjpeg
WorkingDirectory=/home/daniel/gits/MultiRoombaRover/roomcam-service
ExecStart=/usr/bin/env bash /home/daniel/gits/MultiRoombaRover/roomcam-service/room-cam-snapshot.sh
Restart=always
RestartSec=2
User=root
Group=root
[Install]
WantedBy=multi-user.target
+33 -2
View File
@@ -8,6 +8,37 @@ admins:
discord_id: "0987654321"
lockdown: true
media:
# Base address for mediaMTX (scheme + host + optional port). The UI will always request
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
whepBaseUrl: "http://192.168.0.86:8889"
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
homeAssistant:
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
entities:
- id: "light.lab_main"
name: "Lab Lights"
- id: "switch.dock_power"
name: "Dock Power"
# type is optional; if omitted it is inferred from the entity id (light/switch)
roomCameras:
- id: "lobby"
name: "Lobby Camera"
description: "Wide shot of the staging area."
url: "http://192.168.0.50/snapshot.jpg"
- id: "workshop"
name: "Workshop Bench"
description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg"
discord:
token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
channels:
announcements: "123456789012345678"
adminAlerts: "123456789012345678"
chatBridge: "123456789012345678"
roles:
announcementPing: "123456789012345678"
adminPing: "123456789012345678"
+7
View File
@@ -8,15 +8,22 @@ require('./src/helpers/sensorDecoder');
require('./src/services/alertService');
require('./src/services/authService');
require('./src/services/eventBus');
require('./src/services/modeManager');
require('./src/services/lockdownGuard');
require('./src/services/roverManager');
require('./src/services/commandService');
require('./src/services/roverConnectionService');
require('./src/services/assignmentService');
require('./src/services/nicknameService');
require('./src/services/chatService');
require('./src/services/videoSessions');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
require('./src/services/roomCameraSocketService');
require('./src/services/logStreamService');
require('./src/services/homeAssistantService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/discordBotService');
require('./src/services/httpServer');
+11 -4
View File
@@ -15,8 +15,17 @@ hls: no
webrtc: yes
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcAdditionalHosts: []
webrtcICEServers2: []
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
webrtcICEServers2:
# Google public STUN (world-wide, very commonly used)
- url: stun:stun.l.google.com:19302
- url: stun:stun1.l.google.com:19302
- url: stun:stun2.l.google.com:19302
- url: stun:stun3.l.google.com:19302
- url: stun:stun4.l.google.com:19302
# Cloudflare STUN (anycast, global PoPs)
- url: stun:stun.cloudflare.com:3478
srt: yes
srtAddress: :9000
@@ -33,5 +42,3 @@ paths:
all:
source: publisher
sourceOnDemand: no
# webrtcMaxPlayoutDelay: 0
# alwaysRemuxWhep: no
+271
View File
@@ -9,7 +9,9 @@
"version": "0.1.0",
"dependencies": {
"bcrypt": "^6.0.0",
"discord.js": "^14.25.1",
"express": "^4.19.2",
"home-assistant-js-websocket": "^3.1.2",
"js-yaml": "^4.1.1",
"morgan": "^1.10.0",
"socket.io": "^4.7.5",
@@ -20,6 +22,169 @@
"nodemon": "^3.1.0"
}
},
"node_modules/@discordjs/builders": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz",
"integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.1",
"@discordjs/util": "^1.1.1",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.31",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.1.1",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -44,6 +209,25 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -339,6 +523,42 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/discord-api-types": {
"version": "0.38.34",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.34.tgz",
"integrity": "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/discord.js": {
"version": "14.25.1",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/builders": "^1.13.0",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -541,6 +761,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -695,6 +921,12 @@
"node": ">= 0.4"
}
},
"node_modules/home-assistant-js-websocket": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-3.1.2.tgz",
"integrity": "sha512-HzaJtuhufBkppfXerla5cJHSP8uXzLoSRV+XPCcYlOttiKzqLQHD3sPm2T9UHTHPPppy3jRZtaA86ISfH3GK/g==",
"license": "Apache-2.0"
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -803,6 +1035,24 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT"
},
"node_modules/magic-bytes.js": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz",
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1523,6 +1773,18 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1543,6 +1805,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+2
View File
@@ -9,7 +9,9 @@
},
"dependencies": {
"bcrypt": "^6.0.0",
"discord.js": "^14.25.1",
"express": "^4.19.2",
"home-assistant-js-websocket": "^3.1.2",
"js-yaml": "^4.1.1",
"morgan": "^1.10.0",
"socket.io": "^4.7.5",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+10 -4
View File
@@ -2,11 +2,17 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-BvgUoEqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwQ38q3M.css">
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CnvHZcbU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css">
</head>
<body>
<div id="root"></div>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Multi Roomba Rover",
"short_name": "MRR",
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#020617",
"icons": [
{
"src": "/bitmap.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+4
View File
@@ -5,4 +5,8 @@ const io = new SocketIOServer(httpServer, {
cors: { origin: '*' },
});
// Allow more service listeners without warnings.
io.sockets.setMaxListeners(30);
io.of('/').setMaxListeners(30);
module.exports = io;
+4 -4
View File
@@ -9,10 +9,10 @@ const CHARGING_STATE = {
};
const OI_MODES = {
0: 'off',
1: 'passive',
2: 'safe',
3: 'full',
1: 'off',
2: 'passive',
3: 'safe',
4: 'full',
};
const GROUP100_LAYOUT = [
+16
View File
@@ -129,6 +129,21 @@ function releaseAssignment(socket, roverId) {
assignmentEvents.emit('update', socket.id);
}
function forceRelease(roverId, socketId) {
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
if (assignments.get(socketId) === roverId) {
assignments.delete(socketId);
}
waiting.delete(socketId);
if (socket) {
roverManager.releaseControl(roverId, socket);
logger.info('Force released socket from rover', socketId, roverId);
} else {
logger.warn('Force release: socket not found', socketId, roverId);
}
assignmentEvents.emit('update', socketId);
}
function pickRover() {
const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
@@ -161,4 +176,5 @@ function describeAssignment(socketId) {
module.exports = {
assignmentEvents,
describeAssignment,
forceRelease,
};
+175
View File
@@ -0,0 +1,175 @@
const logger = require('../globals/logger').child('batteryManager');
const { publishEvent } = require('./eventBus');
const { managerEvents, lockRover, rovers } = require('./roverManager');
const STATES = {
NORMAL: 'normal',
WARN: 'warn',
URGENT: 'urgent',
DOCKED: 'docked',
CHARGING: 'charging',
FULL: 'full',
LOCKED: 'locked',
};
const WAITING_UNLOCK_MS = 5 * 60 * 1000;
const roverState = new Map(); // roverId -> state snapshot
function getState(roverId) {
if (!roverState.has(roverId)) {
roverState.set(roverId, {
lastPercent: null,
warned: false,
urgent: false,
onDock: false,
dockedCharging: false,
charging: false,
batteryLocked: false,
waitingSince: null,
});
}
return roverState.get(roverId);
}
function shouldLock(record) {
return record.lockReason == null || record.lockReason === 'battery';
}
function handleSensorEvent({ roverId, sensors, batteryState }) {
if (!roverId) return;
const record = rovers.get(roverId);
if (!record) return;
const state = getState(roverId);
const config = record.meta?.battery || {};
const lockable = shouldLock(record);
if (batteryState?.percent != null) {
state.lastPercent = batteryState.percent;
}
if (!state.warned && batteryState?.warnActive) {
state.warned = true;
publishEvent({
source: 'batteryManager',
type: 'battery.warn',
payload: { roverId, batteryState },
});
}
if (!state.urgent && batteryState?.urgentActive) {
state.urgent = true;
publishEvent({
source: 'batteryManager',
type: 'battery.urgent',
payload: { roverId, batteryState },
});
}
const chargingState = sensors?.chargingState?.code;
const chargingSources = sensors?.chargingSources;
const onDock = Boolean(chargingSources?.homeBase);
if (onDock && !state.onDock) {
state.onDock = true;
publishEvent({
source: 'batteryManager',
type: 'battery.docked',
payload: { roverId, batteryState },
});
} else if (!onDock && state.onDock) {
state.onDock = false;
publishEvent({
source: 'batteryManager',
type: 'battery.undocked',
payload: { roverId, batteryState },
});
}
const chargingLabel = sensors?.chargingState?.label?.toLowerCase();
const chargingByLabel =
chargingLabel === 'waiting' || chargingLabel === 'full charging' || chargingLabel === 'trickle charging';
const chargingByCode = chargingState === 2 || chargingState === 3 || chargingState === 4;
const isCharging = chargingByLabel || chargingByCode;
if (isCharging && !state.charging) {
state.charging = true;
publishEvent({
source: 'batteryManager',
type: 'battery.charging.start',
payload: { roverId, batteryState },
});
} else if (!isCharging && state.charging) {
state.charging = false;
publishEvent({
source: 'batteryManager',
type: 'battery.charging.stop',
payload: { roverId, batteryState },
});
}
const dockedCharging = onDock && isCharging;
if (dockedCharging && !state.dockedCharging) {
state.dockedCharging = true;
} else if (!dockedCharging && state.dockedCharging) {
state.dockedCharging = false;
}
const waitingState = chargingState === 4;
if (waitingState && state.waitingSince == null) {
state.waitingSince = Date.now();
} else if (!waitingState && state.waitingSince != null) {
state.waitingSince = null;
}
const waitingLongEnough =
waitingState && state.waitingSince != null && Date.now() - state.waitingSince >= WAITING_UNLOCK_MS;
const warnThreshold =
typeof config?.Warn === 'number' ? config.Warn : batteryState?.warn ?? null;
const fullThreshold =
typeof config?.Full === 'number' ? config.Full : batteryState?.full ?? null;
const chargeMah = batteryState?.charge ?? null;
let waitingPercent = batteryState?.percent ?? null;
if (chargeMah != null && warnThreshold != null && fullThreshold != null && fullThreshold > warnThreshold) {
waitingPercent = (chargeMah - warnThreshold) / (fullThreshold - warnThreshold);
waitingPercent = Math.max(0, Math.min(1, waitingPercent));
}
const isFull =
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
(batteryState?.percent != null && batteryState.percent >= 0.99);
const needsCharge = state.warned;
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
logger.info('Auto-locking rover for charging', { roverId });
lockRover(roverId, true, { reason: 'battery' });
state.batteryLocked = true;
publishEvent({
source: 'batteryManager',
type: 'battery.locked',
payload: { roverId, batteryState },
});
}
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.5;
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
if (shouldUnlock) {
logger.info('Unlocking rover after charge', {
roverId,
isFull,
docked: dockedCharging,
waitingState,
waitingPercent,
waitedMs: state.waitingSince ? Date.now() - state.waitingSince : null,
});
lockRover(roverId, false, { reason: 'battery' });
state.batteryLocked = false;
state.warned = false;
state.urgent = false;
state.waitingSince = null;
publishEvent({
source: 'batteryManager',
type: 'battery.unlocked',
payload: { roverId, batteryState },
});
}
}
managerEvents.on('sensor', handleSensorEvent);
+196
View File
@@ -0,0 +1,196 @@
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const logger = require('../globals/logger').child('chatService');
const { publishEvent, subscribe } = require('./eventBus');
const { getRole } = require('./roleService');
const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService');
const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
const rateBuckets = new Map(); // socketId -> [timestamps]
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
const DUPLICATE_WINDOW_MS = 15000;
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
function withinRateLimit(socketId) {
const now = Date.now();
const entries = rateBuckets.get(socketId) || [];
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
next.push(now);
rateBuckets.set(socketId, next);
return next.length <= RATE_LIMIT_MAX;
}
function hasProfanity(text) {
const lower = text.toLowerCase();
return PROFANITY_LIST.some((word) => lower.includes(word));
}
function isDuplicate(socketId, text) {
const prev = lastMessageBySocket.get(socketId);
const now = Date.now();
if (!prev) {
lastMessageBySocket.set(socketId, { text, ts: now });
return false;
}
lastMessageBySocket.set(socketId, { text, ts: now });
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
}
function isKeymash(text) {
if (!text) return false;
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
return false;
}
function resolveRoverId(socketId) {
const primary = roverManager.getPrimaryRoverForSocket(socketId);
if (primary) return primary;
const assignment = describeAssignment(socketId);
return assignment?.roverId || null;
}
function normalizeUserText(raw) {
if (typeof raw !== 'string') return '';
return raw.replace(/\\n/g, '\n');
}
function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
return {
id: uuidv4(),
ts: Date.now(),
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
fromDiscord: Boolean(meta.fromDiscord),
text,
tts: meta.tts || null,
};
}
function broadcastMessage(message) {
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
}
function normalizeTtsOptions(raw = {}) {
if (!raw || typeof raw !== 'object') return null;
const speak = raw.speak !== false;
if (!speak) return null;
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
if (typeof pitch === 'number') {
pitch = Math.max(0, Math.min(99, pitch));
}
return { speak, engine, voice, pitch };
}
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
const role = getRole(socket);
// if (role === 'spectator') {
// cb({ error: 'Spectators cannot chat' });
// return;
// }
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) {
cb({ error: 'Message required' });
return;
}
if (!withinRateLimit(socket.id)) {
cb({ error: 'Slow down' });
return;
}
if (clean.length > 256) {
cb({ error: 'Message too long' });
return;
}
if (hasProfanity(clean)) {
cb({ error: 'Message blocked' });
return;
}
// if (isDuplicate(socket.id, clean)) {
// cb({ error: 'Duplicate message' });
// return;
// }
if (isKeymash(clean)) {
cb({ error: 'Message looks like spam' });
return;
}
const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
broadcastMessage(message);
maybeSpeak(socket, message, ttsOptions);
cb({ success: true });
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const audio = record?.meta?.audio || {};
const ttsEnabled = Boolean(audio.ttsEnabled);
if (!ttsEnabled) return;
if (!roverManager.canDrive(message.roverId, socket)) return;
try {
issueCommand(message.roverId, {
type: 'tts',
tts: {
text: message.text,
engine: ttsOptions.engine,
voice: ttsOptions.voice,
pitch: ttsOptions.pitch,
speak: true,
},
});
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
} catch (err) {
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
}
}
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null }) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean || clean.length > 256) {
throw new Error('Message invalid');
}
if (hasProfanity(clean)) {
throw new Error('Message blocked');
}
if (isKeymash(clean)) {
throw new Error('Message looks like spam');
}
const message = buildMessage(null, clean, {
nickname,
role,
roverId,
fromDiscord: true,
});
logger.info('External chat message', { roverId, nickname });
broadcastMessage(message);
return message;
}
io.on('connection', (socket) => {
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
});
subscribe('chat:message', ({ payload }) => {
if (!payload) return;
io.emit('chat:message', payload);
});
module.exports = {
handleIncoming,
sendExternalMessage,
};
+6
View File
@@ -49,6 +49,12 @@ io.on('connection', (socket) => {
const payload = data ? { ...data } : {};
const id = issueCommand(roverId, { type, ...payload });
logger.info('Queued command', socket.id, roverId, type);
try {
const { recordActivity } = require('./turnService');
recordActivity(roverId, socket.id);
} catch (err) {
// best effort; ignore activity update errors
}
reply({ id });
} catch (err) {
logger.warn('Command rejected', socket.id, err.message);
+436
View File
@@ -0,0 +1,436 @@
const {
Client,
GatewayIntentBits,
Partials,
ActivityType,
EmbedBuilder,
} = require('discord.js');
const logger = require('../globals/logger').child('discordBot');
const { loadConfig } = require('../helpers/configLoader');
const { subscribe } = require('./eventBus');
const { getRoster, lockRover } = require('./roverManager');
const { MODES, getMode, setMode } = require('./modeManager');
const { sendExternalMessage } = require('./chatService');
const config = loadConfig();
const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token);
const adminIds = new Set(
(config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean),
);
if (!enabled) {
logger.info('Discord bot disabled; missing token in config.discord.token');
return;
}
const intents = [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
];
const client = new Client({
intents,
partials: [Partials.Channel],
});
const channelCache = new Map();
let skippedFirstModeAnnouncement = false;
function sanitizeMentions(text) {
if (!text) return '';
return String(text)
// strip mention tokens
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
// neutralize everyone/here
.replace(/@everyone/gi, '[everyone]')
.replace(/@here/gi, '[here]');
}
function formatRoverStatus(rover) {
if (!rover) return 'Unknown rover';
const percent = rover.batteryState?.percentDisplay;
const battery = percent != null ? `${percent}%` : 'n/a';
const lockLabel = rover.locked ? '🔒 locked' : '🔓 unlocked';
return `**${rover.name || rover.id}** — ${lockLabel} — battery ${battery}`;
}
function countReady() {
const roster = getRoster();
const total = roster.length;
const ready = roster.filter((r) => !r.locked).length;
return { ready, total };
}
async function updatePresence() {
if (!client?.user) return;
const { ready, total } = countReady();
try {
await client.user.setPresence({
activities: [{ name: `${ready}/${total} Rovers Ready`, type: ActivityType.Watching }],
status: 'online',
});
} catch (err) {
logger.warn('Failed to update Discord presence', err.message);
}
}
async function fetchChannel(id) {
if (!id) return null;
if (channelCache.has(id)) return channelCache.get(id);
try {
const channel = await client.channels.fetch(id);
if (channel) {
channelCache.set(id, channel);
return channel;
}
} catch (err) {
logger.warn('Failed to fetch Discord channel', { id, error: err.message });
}
return null;
}
async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }, sanitizeContent = true) {
const channel = await fetchChannel(id);
if (!channel) return;
try {
const messageContent = sanitizeContent ? sanitizeMentions(content) : content;
await channel.send({ content: messageContent, allowedMentions, ...options });
} catch (err) {
logger.warn('Failed to send Discord message', { id, error: err.message });
}
}
function isAdminUser(discordId) {
return adminIds.has(String(discordId || '').trim());
}
function formatHelp() {
return [
'**Rover Bot Commands**',
'`rs help` — show this help',
'`rs status [id]` — show rover status (all or one)',
'`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
].join('\n');
}
function findRover(id) {
const roster = getRoster();
if (!id) return null;
return roster.find((r) => String(r.id) === String(id) || String(r.name) === String(id));
}
async function handleStatusCommand(message, roverId) {
const roster = getRoster();
if (!roverId) {
const summary = roster.map((r) => formatRoverStatus(r)).join('\n') || 'No rovers online.';
await message.reply({
content: sanitizeMentions(summary.slice(0, 1900)),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
const rover = findRover(roverId);
await message.reply({
content: sanitizeMentions(formatRoverStatus(rover).slice(0, 1900)),
allowedMentions: { parse: [], repliedUser: false },
});
}
async function handleLockCommand(message, roverId, locked) {
if (!roverId) {
await message.reply({
content: 'Specify a rover ID. Example: `rs lock alpha`',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
try {
lockRover(roverId, locked, { reason: 'discord' });
await message.reply({
content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
}
async function handleModeCommand(message, mode) {
const next = String(mode || '').toLowerCase();
if (!Object.values(MODES).includes(next)) {
await message.reply({
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
try {
setMode(next, null, { force: true });
await message.reply({
content: sanitizeMentions(`Mode set to ${next}.`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to set mode: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
}
async function handleCommand(message) {
if (message.author.bot) return;
const content = (message.content || '').trim();
if (!content.toLowerCase().startsWith('rs')) return;
if (!isAdminUser(message.author.id)) {
return; // ignore non-admins
}
const tokens = content.split(/\s+/);
tokens.shift(); // remove prefix
const action = (tokens.shift() || '').toLowerCase();
switch (action) {
case 'help':
await message.reply(formatHelp());
break;
case 'status':
await handleStatusCommand(message, tokens[0]);
break;
case 'lock':
await handleLockCommand(message, tokens[0], true);
break;
case 'unlock':
await handleLockCommand(message, tokens[0], false);
break;
case 'mode':
await handleModeCommand(message, tokens[0]);
break;
default:
await message.reply(formatHelp());
break;
}
}
function formatChatLine(payload) {
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
const rover = payload.roverId ? `**[${payload.roverId}]** ` : '';
const role =
payload.role === 'admin' || payload.role === 'lockdown' ? '**(admin)** ' : '';
return `${rover}${role}${name}: ${payload.text}`;
}
async function handleBridgeInbound(message) {
const bridgeChannelId = discordConfig?.channels?.chatBridge;
if (!bridgeChannelId) return;
if (String(message.channelId) !== String(bridgeChannelId)) return;
if (message.author.bot) return;
const content = (message.content || '').trim();
if (content.toLowerCase().startsWith('rs')) return; // don't echo commands
const nickname =
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
const role = isAdminUser(message.author.id) ? 'admin' : 'user';
try {
sendExternalMessage({
text: content,
nickname,
role,
roverId: null,
});
} catch (err) {
logger.warn('Failed to bridge inbound Discord chat', err.message);
}
}
function buildEmbed({ title, description, color }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
if (description) embed.setDescription(description);
embed.setTimestamp(new Date());
return embed;
}
async function announce({ channelId, content, pingRoleId, color, title, description }) {
if (!channelId) return;
const prefix = pingRoleId ? `<@&${pingRoleId}> ` : '';
const embed = buildEmbed({ title, description, color });
const allowedMentions = pingRoleId ? { roles: [pingRoleId], parse: [] } : { parse: [] };
await sendToChannel(
channelId,
`${prefix}${content || ''}`.trim(),
{ embeds: [embed] },
allowedMentions,
!pingRoleId, // keep role mention intact when pinging
);
}
function handleBusEvent(event) {
const { type, payload } = event || {};
const channels = discordConfig.channels || {};
const roles = discordConfig.roles || {};
switch (type) {
case 'mode.changed':
if (!skippedFirstModeAnnouncement) {
skippedFirstModeAnnouncement = true;
updatePresence();
break;
}
announce({
channelId: channels.announcements,
pingRoleId: roles.announcementPing || null,
color: 0x2196f3,
title: 'Mode Changed',
description: `Server mode set to **${payload?.mode}**`,
});
updatePresence();
break;
case 'rover.locked':
announce({
channelId: channels.announcements,
color: 0xf0b651,
title: 'Rover Locked',
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
});
updatePresence();
break;
case 'rover.unlocked':
announce({
channelId: channels.announcements,
pingRoleId: roles.announcementPing || null,
color: 0x4caf50,
title: 'Rover Unlocked',
description: `${payload?.roverId} unlocked.`,
});
updatePresence();
break;
case 'rover.online':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0x4caf50,
title: 'Rover Online',
description: `${payload?.roverId} is online.`,
});
updatePresence();
break;
case 'rover.offline':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xe53935,
title: 'Rover Offline',
description: `${payload?.roverId} went offline.`,
});
updatePresence();
break;
case 'battery.warn':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xf0b651,
title: 'Battery Warn',
description: `${payload?.roverId} at ${payload?.batteryState?.percentDisplay ?? 'low'}%.`,
});
break;
case 'battery.urgent':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xe53935,
title: 'Battery Urgent',
description: `${payload?.roverId} at ${payload?.batteryState?.percentDisplay ?? 'urgent'}%.`,
});
break;
case 'battery.docked':
announce({
channelId: channels.adminAlerts,
color: 0x2196f3,
title: 'Docked',
description: `${payload?.roverId} docked.`,
});
break;
case 'battery.undocked':
announce({
channelId: channels.adminAlerts,
color: 0x2196f3,
title: 'Undocked',
description: `${payload?.roverId} undocked.`,
});
break;
case 'battery.charging.start':
announce({
channelId: channels.adminAlerts,
color: 0x2196f3,
title: 'Charging Started',
description: `${payload?.roverId} started charging.`,
});
break;
case 'battery.charging.stop':
announce({
channelId: channels.adminAlerts,
color: 0xf0b651,
title: 'Charging Stopped',
description: `${payload?.roverId} stopped charging.`,
});
break;
case 'battery.locked':
announce({
channelId: channels.adminAlerts,
color: 0xf0b651,
title: 'Locked for Charging',
description: `${payload?.roverId} locked for charging.`,
});
updatePresence();
break;
case 'battery.unlocked':
announce({
channelId: channels.adminAlerts,
color: 0x4caf50,
title: 'Unlocked after Charging',
description: `${payload?.roverId} unlocked after charging.`,
});
updatePresence();
break;
default:
break;
}
}
function handleChatBridgeOutbound(event) {
const payload = event?.payload;
if (!payload || payload.fromDiscord) return;
const bridgeChannelId = discordConfig?.channels?.chatBridge;
if (!bridgeChannelId) return;
const line = formatChatLine(payload);
const text = line.length > 1900 ? `${line.slice(0, 1897)}...` : line;
sendToChannel(bridgeChannelId, text, {}, { parse: [] });
}
client.on('messageCreate', async (message) => {
try {
await handleCommand(message);
await handleBridgeInbound(message);
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
});
client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
updatePresence();
});
subscribe('*', handleBusEvent);
subscribe('chat:message', handleChatBridgeOutbound);
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
});
module.exports = {};
+57
View File
@@ -0,0 +1,57 @@
const EventEmitter = require('events');
const baseLogger = require('../globals/logger');
const logger = baseLogger.child ? baseLogger.child('eventBus') : baseLogger;
const eventBus = new EventEmitter();
/**
* Publish a structured event onto the server-wide bus.
* @param {object} options
* @param {string} options.source - Origin module identifier.
* @param {string} options.type - Event type name.
* @param {any} [options.payload] - Optional event payload.
*/
function publishEvent({ source, type, payload = null }) {
if (!source) {
throw new Error('eventBus.publishEvent requires source');
}
if (!type) {
throw new Error('eventBus.publishEvent requires type');
}
const event = {
source,
type,
payload,
ts: Date.now(),
};
const logFn = typeof logger.debug === 'function' ? 'debug' : 'info';
logger[logFn](`Publishing event ${type}`, { source });
eventBus.emit(type, event);
eventBus.emit('*', event);
}
/**
* Subscribe to events of a given type.
* @param {string} type
* @param {(event: object) => void} handler
*/
function subscribe(type, handler) {
eventBus.on(type, handler);
return () => eventBus.off(type, handler);
}
/**
* Subscribe to all events on the bus.
* @param {(event: object) => void} handler
*/
function subscribeAll(handler) {
eventBus.on('*', handler);
return () => eventBus.off('*', handler);
}
module.exports = {
eventBus,
publishEvent,
subscribe,
subscribeAll,
};
+267
View File
@@ -0,0 +1,267 @@
const EventEmitter = require('events');
const WebSocket = require('ws');
const { createConnection, subscribeEntities, callService, Auth } = require('home-assistant-js-websocket');
const io = require('../globals/io');
const logger = require('../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../helpers/configLoader');
const { getMode } = require('./modeManager');
const { isAdmin } = require('./roleService');
// home-assistant-js-websocket expects a global WebSocket in Node.
if (!global.WebSocket) {
global.WebSocket = WebSocket;
}
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const events = new EventEmitter();
const entityConfig = new Map(); // entityId -> { id, name, type }
const entityState = new Map(); // entityId -> normalized state
let connection = null;
let unsubscribeEntities = null;
let reconnectTimer = null;
let connected = false;
const enabled = Boolean(haConfig?.url && haConfig?.token);
function buildAuth() {
const token = haConfig?.token?.trim();
const url = haConfig?.url?.trim();
if (!token || !url) {
throw new Error('Home Assistant url/token missing');
}
// Long-lived tokens last 10 years; set a far future expiry to avoid refresh attempts.
return new Auth(
{
hassUrl: url.replace(/\/+$/, ''),
access_token: token,
expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10,
refresh_token: null,
clientId: 'multiroomba-rover',
},
null,
);
}
function inferType(entityId, explicitType) {
if (explicitType === 'light' || explicitType === 'switch') {
return explicitType;
}
const domain = String(entityId || '').split('.')[0];
if (domain === 'light') return 'light';
return 'switch';
}
function normalizeConfigEntry(entry) {
if (!entry) return null;
const id = entry.id || entry.entityId || entry.entity_id;
if (!id) return null;
const type = inferType(id, entry.type);
const name = entry.name || null;
return { id: String(id), name, type };
}
function loadEntityConfig() {
entityConfig.clear();
const list = Array.isArray(haConfig?.entities) ? haConfig.entities : [];
list.forEach((entry) => {
const normalized = normalizeConfigEntry(entry);
if (normalized) {
entityConfig.set(normalized.id, normalized);
if (!entityState.has(normalized.id)) {
entityState.set(normalized.id, buildState(normalized, null));
}
}
});
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
}
function buildState(meta, raw) {
if (!meta) return null;
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
if (!raw) {
return {
id: meta.id,
name,
type: meta.type,
state: 'unknown',
available: false,
lastChanged: null,
lastUpdated: null,
};
}
const rawState = raw.state;
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
return {
id: meta.id,
name,
type: meta.type,
state,
available: !unavailable,
lastChanged: raw.last_changed || null,
lastUpdated: raw.last_updated || null,
};
}
function emitUpdate() {
events.emit('update', getState());
}
function emitStatus() {
events.emit('status', getState());
}
function handleEntitySnapshot(snapshot = {}) {
let changed = false;
entityConfig.forEach((meta, id) => {
const raw = snapshot[id];
const next = buildState(meta, raw);
const prev = entityState.get(id);
if (
!prev ||
prev.state !== next.state ||
prev.available !== next.available ||
prev.lastChanged !== next.lastChanged
) {
entityState.set(id, next);
changed = true;
}
});
if (changed) {
emitUpdate();
}
}
function teardownConnection() {
if (unsubscribeEntities) {
try {
unsubscribeEntities();
} catch (err) {
logger.warn('Failed to unsubscribe entity stream', err.message);
}
}
unsubscribeEntities = null;
if (connection) {
try {
connection.close();
} catch (err) {
logger.warn('Error closing Home Assistant connection', err.message);
}
}
connection = null;
const wasConnected = connected;
connected = false;
if (wasConnected) {
emitStatus();
}
}
function scheduleReconnect(delayMs = 5000) {
if (!enabled) return;
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delayMs);
}
async function connect() {
if (!enabled) {
logger.info('Home Assistant integration disabled; missing url/token in config');
return;
}
if (connection) {
return;
}
try {
const auth = buildAuth();
connection = await createConnection({ auth, setupRetry: 0 });
connected = true;
emitStatus();
logger.info('Connected to Home Assistant');
unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot);
connection.addEventListener('disconnected', () => {
logger.warn('Home Assistant connection lost');
teardownConnection();
scheduleReconnect();
});
} catch (err) {
logger.warn('Home Assistant connection failed', err.message);
teardownConnection();
scheduleReconnect();
}
}
async function setEntityState(entityId, desiredState) {
if (!enabled) {
throw new Error('Home Assistant not configured');
}
const meta = entityConfig.get(entityId);
if (!meta) {
throw new Error('Unknown Home Assistant entity');
}
if (!connection) {
throw new Error('Home Assistant not connected');
}
const nextState = desiredState === 'on' ? 'on' : 'off';
const domain = meta.type === 'light' ? 'light' : 'switch';
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
await callService(connection, domain, service, { entity_id: entityId });
logger.info('Issued Home Assistant command', { entityId, domain, service });
}
async function toggleEntity(entityId) {
const current = entityState.get(entityId);
const nextState = current?.state === 'on' ? 'off' : 'on';
return setEntityState(entityId, nextState);
}
function getState() {
const entities = Array.from(entityConfig.values()).map(
(meta) => entityState.get(meta.id) || buildState(meta, null),
);
return { enabled, connected, entities };
}
loadEntityConfig();
connect();
io.on('connection', (socket) => {
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
if ((getMode() === 'admin' || getMode() === 'lockdown') && isAdmin(socket) !== true) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
try {
if (!entityId) throw new Error('entityId required');
await toggleEntity(entityId);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
if ((getMode() === 'admin' || getMode() === 'lockdown') && isAdmin(socket) !== true) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
try {
if (!entityId) throw new Error('entityId required');
await setEntityState(entityId, state);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getState,
toggleEntity,
setEntityState,
homeAssistantEvents: events,
};
+10 -3
View File
@@ -2,6 +2,7 @@ const EventEmitter = require('events');
const io = require('../globals/io');
const { sendAlert, COLORS } = require('./alertService');
const { isAdmin, isLockdownAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const MODES = {
OPEN: 'open',
@@ -10,7 +11,7 @@ const MODES = {
LOCKDOWN: 'lockdown',
};
let currentMode = MODES.OPEN;
let currentMode = MODES.ADMIN;
const modeEvents = new EventEmitter();
function canChangeMode(socket, nextMode) {
@@ -20,11 +21,12 @@ function canChangeMode(socket, nextMode) {
return isAdmin(socket);
}
function setMode(nextMode, socket) {
function setMode(nextMode, socket, options = {}) {
if (!Object.values(MODES).includes(nextMode)) {
throw new Error(`Unknown mode ${nextMode}`);
}
if (!canChangeMode(socket, nextMode)) {
const force = Boolean(options.force);
if (!force && !canChangeMode(socket, nextMode)) {
throw new Error('Not authorized to change mode');
}
if (currentMode === nextMode) {
@@ -36,6 +38,11 @@ function setMode(nextMode, socket) {
title: 'Mode Changed',
message: `Server mode set to ${nextMode}`,
});
publishEvent({
source: 'modeManager',
type: 'mode.changed',
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
});
modeEvents.emit('change', currentMode);
io.emit('mode', { mode: currentMode });
return currentMode;
+50
View File
@@ -1 +1,51 @@
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('nicknameService');
const { getRole } = require('./roleService');
const nicknameEvents = new EventEmitter();
function sanitizeNickname(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
return trimmed.slice(0, 32);
}
function getNickname(socket) {
return socket?.data?.nickname || '';
}
function setNickname(socket, nickname) {
if (!socket) return null;
const role = getRole(socket);
// if (role === 'spectator') {
// throw new Error('Spectators cannot set nicknames');
// }
const value = sanitizeNickname(nickname);
if (!value) {
throw new Error('Nickname required');
}
socket.data = socket.data || {};
socket.data.nickname = value;
nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
logger.info('Nickname set', { socketId: socket.id, nickname: value });
return value;
}
io.on('connection', (socket) => {
socket.on('nickname:set', ({ nickname } = {}, cb = () => {}) => {
try {
const value = setNickname(socket, nickname);
cb({ success: true, nickname: value });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getNickname,
setNickname,
nicknameEvents,
};
+57
View File
@@ -0,0 +1,57 @@
const EventEmitter = require('events');
const logger = require('../globals/logger').child('roomCameraService');
const { loadConfig } = require('../helpers/configLoader');
const events = new EventEmitter();
const config = loadConfig();
const cameraMap = new Map();
function normalizeCamera(camera) {
if (!camera) return null;
const id = camera.id || camera.name;
if (!id) {
logger.warn('Room camera missing id', camera);
return null;
}
if (!camera.url) {
logger.warn('Room camera missing url', { id, camera });
return null;
}
return {
id: String(id),
name: camera.name || camera.id || String(id),
description: camera.description || null,
url: camera.url,
};
}
function loadFromConfig() {
cameraMap.clear();
const list = Array.isArray(config.roomCameras) ? config.roomCameras : [];
list.forEach((camera) => {
const normalized = normalizeCamera(camera);
if (normalized) {
cameraMap.set(normalized.id, normalized);
}
});
logger.info('Loaded room cameras', { count: cameraMap.size });
events.emit('update', getRoomCameras());
}
function getRoomCameras() {
return Array.from(cameraMap.values());
}
function getRoomCamera(id) {
if (!id) return null;
return cameraMap.get(String(id)) || null;
}
loadFromConfig();
module.exports = {
getRoomCameras,
getRoomCamera,
roomCameraEvents: events,
};
@@ -0,0 +1,92 @@
const EventEmitter = require('events');
const logger = require('../globals/logger').child('roomCameraSnapshot');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const POLL_INTERVAL_MS = 500; // 2 fps target
const FETCH_TIMEOUT_MS = 2000;
const STALE_AFTER_MS = 4000;
const cameraState = new Map(); // id -> {frame, ts, stale, error, failures, fetching}
const events = new EventEmitter(); // frame, status
let pollTimer = null;
function markState(id, updates = {}) {
const prev = cameraState.get(id) || {};
const next = { ...prev, ...updates };
if (next.ts != null) {
next.stale = Date.now() - next.ts > STALE_AFTER_MS || !!next.stale;
} else {
next.stale = true;
}
cameraState.set(id, next);
return next;
}
async function fetchSnapshot(camera) {
const { id, url } = camera;
const state = cameraState.get(id);
if (!url || state?.fetching) return;
markState(id, { fetching: true });
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: abortController.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const ts = Date.now();
const next = markState(id, { frame: buffer, ts, error: null, failures: 0 });
events.emit('frame', { id, buffer, ts, stale: !!next.stale });
} catch (err) {
const failures = (state?.failures || 0) + 1;
markState(id, { error: err.message, failures });
events.emit('status', { id, error: err.message });
logger.warn('Snapshot fetch failed', { id, err: err.message });
} finally {
clearTimeout(timeout);
markState(id, { fetching: false });
}
}
function stopAll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
cameraState.clear();
}
function startAll() {
stopAll();
pollTimer = setInterval(() => {
getRoomCameras().forEach((camera) => fetchSnapshot(camera));
}, POLL_INTERVAL_MS);
getRoomCameras().forEach((camera) => fetchSnapshot(camera));
logger.info('Started snapshot polling', { count: getRoomCameras().length });
}
function getState(id) {
const state = cameraState.get(id);
if (!state) return null;
const stale = state.ts == null || Date.now() - state.ts > STALE_AFTER_MS;
return {
frame: state.frame || null,
ts: state.ts || null,
stale,
error: state.error || null,
};
}
roomCameraEvents.on('update', () => {
logger.info('Room cameras changed; restarting snapshot pollers');
startAll();
});
startAll();
module.exports = {
roomCameraStreamEvents: events,
getRoomCameraState: getState,
};
@@ -0,0 +1,154 @@
const io = require('../globals/io');
const logger = require('../globals/logger').child('roomCameraSocket');
const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const { getRoomCamera, getRoomCameras } = require('./roomCameraService');
const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnapshotService');
const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000;
function passesMode(socket) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
return isLockdownAdmin(socket);
}
if (mode === MODES.ADMIN) {
const role = getRole(socket);
return role === 'spectator' || isAdmin(socket);
}
return true;
}
function canViewRoomCamera(socket) {
return passesMode(socket);
}
const cameraSubscribers = new Map(); // id -> Set(socketId)
const socketSubscriptions = new Map(); // socketId -> Set(id)
const subscribeBuckets = new Map(); // socketId -> { start, count }
function addSubscription(socket, cameraId) {
if (!cameraSubscribers.has(cameraId)) {
cameraSubscribers.set(cameraId, new Set());
}
cameraSubscribers.get(cameraId).add(socket.id);
if (!socketSubscriptions.has(socket.id)) {
socketSubscriptions.set(socket.id, new Set());
}
socketSubscriptions.get(socket.id).add(cameraId);
}
function removeSubscription(socketId, cameraId) {
const bucket = cameraSubscribers.get(cameraId);
if (bucket) {
bucket.delete(socketId);
if (bucket.size === 0) {
cameraSubscribers.delete(cameraId);
}
}
const socketBucket = socketSubscriptions.get(socketId);
if (socketBucket) {
socketBucket.delete(cameraId);
if (socketBucket.size === 0) {
socketSubscriptions.delete(socketId);
}
}
}
function removeAllSubscriptions(socketId) {
const bucket = socketSubscriptions.get(socketId);
if (!bucket) return;
bucket.forEach((cameraId) => removeSubscription(socketId, cameraId));
}
function allowSubscribe(socketId) {
const now = Date.now();
let bucket = subscribeBuckets.get(socketId);
if (!bucket || now - bucket.start >= SUBSCRIBE_WINDOW_MS) {
bucket = { start: now, count: 0 };
}
bucket.count += 1;
subscribeBuckets.set(socketId, bucket);
return bucket.count <= SUBSCRIBE_LIMIT;
}
function sendFrame(socket, cameraId, payload, buffer) {
socket.emit('roomCamera:frame', { id: cameraId, ...payload }, buffer);
}
function sendStatus(socket, cameraId, status) {
socket.emit('roomCamera:status', { id: cameraId, ...status });
}
roomCameraStreamEvents.on('frame', ({ id, buffer, ts, stale }) => {
const bucket = cameraSubscribers.get(id);
if (!bucket || !buffer) return;
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
sendFrame(socket, id, { ts, stale: !!stale }, buffer);
});
});
roomCameraStreamEvents.on('status', ({ id, error }) => {
const bucket = cameraSubscribers.get(id);
if (!bucket) return;
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
sendStatus(socket, id, { error: error || null });
});
});
io.on('connection', (socket) => {
socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: getRoomCameras().map((cam) => cam.id);
const uniqueIds = Array.from(new Set(list));
try {
if (!allowSubscribe(socket.id)) {
cb({ error: 'Rate limited' });
return;
}
if (!canViewRoomCamera(socket)) {
throw new Error('Not authorized for room camera');
}
const validIds = uniqueIds.filter((id) => !!getRoomCamera(id));
validIds.forEach((cameraId) => addSubscription(socket, cameraId));
validIds.forEach((cameraId) => {
const state = getRoomCameraState(cameraId);
if (state?.frame) {
sendFrame(socket, cameraId, { ts: state.ts, stale: !!state.stale }, state.frame);
}
sendStatus(socket, cameraId, {
ts: state?.ts || null,
stale: state?.stale ?? true,
error: state?.error || null,
});
});
cb({ ok: true, subscribed: validIds });
} catch (err) {
logger.warn('Room camera subscribe failed', { socketId: socket.id, err: err.message });
cb({ error: err.message });
}
});
socket.on('roomCamera:unsubscribe', (payload = {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: [];
list.forEach((cameraId) => removeSubscription(socket.id, cameraId));
});
socket.on('disconnect', () => {
removeAllSubscriptions(socket.id);
subscribeBuckets.delete(socket.id);
});
});
@@ -33,6 +33,11 @@ roverWSS.on('connection', (ws) => {
}
if (msg.type === 'hello') {
roverId = msg.name;
logger.info('Received rover hello', {
roverId,
keys: Object.keys(msg),
cameraServo: msg.cameraServo,
});
roverManager.upsertRover(msg, ws);
roverManager.broadcastRoster();
sendAlert({ color: COLORS.success, title: 'Rover Online', message: roverId });
+82 -4
View File
@@ -5,6 +5,7 @@ const { sendAlert, COLORS } = require('./alertService');
const { parseSensorFrame } = require('../helpers/sensorDecoder');
const { MODES, getMode } = require('./modeManager');
const { isAdmin, roleEvents } = require('./roleService');
const { publishEvent } = require('./eventBus');
const rovers = new Map(); // roverId -> record
const socketToRovers = new Map(); // socketId -> Set(roverId)
@@ -21,6 +22,8 @@ function ensureRecord(id) {
lastSensor: null,
drivers: new Set(),
locked: false,
lockReason: null,
batteryState: null,
room: `rover:${id}`,
lastSeen: Date.now(),
});
@@ -30,6 +33,12 @@ function ensureRecord(id) {
function upsertRover(meta, ws) {
const id = meta.name || meta.id;
if (!meta.cameraServo) {
logger.info('Rover hello missing camera servo block', { id, keys: Object.keys(meta || {}) });
} else {
logger.info('Rover hello camera servo', { id, servo: meta.cameraServo });
}
const isNew = !rovers.has(id);
const record = ensureRecord(id);
record.meta = meta;
record.ws = ws;
@@ -40,6 +49,9 @@ function upsertRover(meta, ws) {
sock?.join(record.room);
});
managerEvents.emit('rover', { roverId: id, action: 'upsert', record });
if (isNew) {
publishEvent({ source: 'roverManager', type: 'rover.online', payload: { roverId: id } });
}
broadcastRoster();
return record;
}
@@ -55,22 +67,45 @@ function removeRover(id) {
});
broadcastRoster();
managerEvents.emit('rover', { roverId: id, action: 'removed' });
publishEvent({ source: 'roverManager', type: 'rover.offline', payload: { roverId: id } });
}
function lockRover(id, locked, actorSocket) {
function lockRover(id, locked, options = {}) {
const record = rovers.get(id);
if (!record) {
throw new Error('Unknown rover');
}
const reason = locked ? options.reason || 'manual' : null;
const silent = Boolean(options.silent);
if (locked) {
record.locked = true;
sendAlert({ color: COLORS.warn, title: 'Rover Locked', message: `${id} locked by admin.` });
record.lockReason = reason;
if (!silent) {
sendAlert({
color: COLORS.warn,
title: 'Rover Locked',
message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`,
});
}
publishEvent({
source: 'roverManager',
type: 'rover.locked',
payload: { roverId: id, reason: record.lockReason },
});
} else {
record.locked = false;
record.lockReason = null;
if (!silent) {
sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` });
}
publishEvent({
source: 'roverManager',
type: 'rover.unlocked',
payload: { roverId: id },
});
}
broadcastRoster();
managerEvents.emit('lock', { roverId: id, locked: record.locked });
managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason });
return record.locked;
}
@@ -79,9 +114,13 @@ function getRoster() {
id: record.id,
name: record.meta?.name || record.id,
battery: record.meta?.battery,
batteryState: record.batteryState,
maxWheelSpeed: record.meta?.maxWheelSpeed,
media: record.meta?.media,
cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio,
locked: record.locked,
lockReason: record.lockReason,
lastSeen: record.lastSeen,
}));
}
@@ -90,17 +129,52 @@ function broadcastRoster() {
io.emit('rovers', getRoster());
}
function computeBatteryState(record, sensors) {
if (!record) return null;
if (!sensors) return record.batteryState;
const config = record.meta?.battery || null;
const charge = sensors?.batteryChargeMah ?? null;
const capacity = sensors?.batteryCapacityMah ?? null;
const full = typeof config?.Full === 'number' ? config.Full : null;
const warn = typeof config?.Warn === 'number' ? config.Warn : null;
const urgent = typeof config?.Urgent === 'number' ? config.Urgent : null;
let percent = null;
if (charge != null && warn != null && full != null && full > warn) {
const span = full - warn;
percent = (charge - warn) / span;
} else if (charge != null && capacity != null && capacity > 0) {
percent = charge / capacity;
}
if (percent != null) {
percent = Math.max(0, Math.min(1, percent));
}
return {
charge,
capacity,
full,
warn,
urgent,
percent,
percentDisplay: percent == null ? null : Math.round(percent * 100),
warnActive: Boolean(warn != null && charge != null && charge <= warn),
urgentActive: Boolean(urgent != null && charge != null && charge <= urgent),
updatedAt: Date.now(),
};
}
function handleSensorFrame(roverId, frame) {
const record = rovers.get(roverId);
if (!record) return;
record.lastSeen = Date.now();
const decoded = parseSensorFrame(frame.data);
record.lastSensor = { raw: frame, decoded };
record.batteryState = computeBatteryState(record, decoded);
io.to(record.room).emit('sensorFrame', {
roverId,
frame,
sensors: decoded,
});
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
}
function removeSocket(socket) {
@@ -268,7 +342,7 @@ io.on('connection', (socket) => {
return;
}
try {
lockRover(roverId, locked);
lockRover(roverId, locked, { reason: 'manual' });
logger.info('Lock state changed', roverId, locked);
cb({ success: true });
} catch (err) {
@@ -283,6 +357,10 @@ io.on('connection', (socket) => {
cb({ error: 'Spectator role required' });
return;
}
if (getMode() === MODES.LOCKDOWN) {
cb({ error: 'Spectating disabled in lockdown' });
return;
}
logger.info('Spectator subscribing to all rovers', socket.id);
for (const record of rovers.values()) {
socket.join(record.room);
+95 -1
View File
@@ -5,9 +5,36 @@ const { getMode, modeEvents } = require('./modeManager');
const roverManager = require('./roverManager');
const { managerEvents } = roverManager;
const assignmentService = require('./assignmentService');
const { getActiveDrivers, turnEvents } = require('./turnService');
const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
const { getNickname, nicknameEvents } = require('./nicknameService');
const { loadConfig } = require('../helpers/configLoader');
const discordInvite = loadConfig().discord?.invite || null;
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
let lastActivitySync = 0;
let pendingActivitySync = null;
function buildUserEntry(socket) {
if (!socket) return null;
const role = getRole(socket);
const assignment = assignmentService.describeAssignment(socket.id);
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
return {
socketId: socket.id,
nickname: getNickname(socket) || null,
role,
roverId: primaryRover || assignment?.roverId || null,
};
}
function buildSession(socket) {
const users = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock))
.filter(Boolean);
return {
socketId: socket?.id || null,
role: getRole(socket),
@@ -15,6 +42,13 @@ function buildSession(socket) {
roster: roverManager.getRoster(),
assignment: assignmentService.describeAssignment(socket?.id || ''),
activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(),
roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(),
users,
discord: {
invite: discordInvite,
},
};
}
@@ -77,6 +111,66 @@ turnEvents.on('activeDriver', () => {
logger.info('Active driver change; syncing all clients');
syncAll();
});
turnEvents.on('queue', (event = {}) => {
const { reason } = event;
if (reason === 'activity') {
const now = Date.now();
const elapsed = now - lastActivitySync;
if (elapsed >= ACTIVITY_SYNC_COOLDOWN_MS) {
lastActivitySync = now;
logger.info('Turn activity; syncing all clients (immediate)');
syncAll();
return;
}
if (!pendingActivitySync) {
const delay = ACTIVITY_SYNC_COOLDOWN_MS - elapsed;
pendingActivitySync = setTimeout(() => {
lastActivitySync = Date.now();
pendingActivitySync = null;
logger.info('Turn activity; syncing all clients (delayed)');
syncAll();
}, delay);
}
return;
}
if (pendingActivitySync) {
clearTimeout(pendingActivitySync);
pendingActivitySync = null;
}
logger.info('Turn queue change; syncing all clients');
syncAll();
});
roomCameraEvents.on('update', () => {
logger.info('Room camera change detected; syncing all clients');
syncAll();
});
homeAssistantEvents.on('update', () => {
logger.info('Home Assistant state change; syncing all clients');
syncAll();
});
homeAssistantEvents.on('status', () => {
logger.info('Home Assistant status change; syncing all clients');
syncAll();
});
nicknameEvents.on('change', ({ socketId }) => {
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
if (socket) {
logger.info('Nickname change; syncing session', socketId);
syncSocket(socket);
} else {
syncAll();
}
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');
syncAll();
}, 20000);
module.exports = {
buildSession,
+146
View File
@@ -6,7 +6,14 @@ const { MODES, getMode, modeEvents } = require('./modeManager');
const driverQueues = new Map(); // roverId -> { queue: [], current: socketId, timer: Timeout | null }
const activeDrivers = new Map();
const TURN_DURATION_MS = 60 * 1000;
const IDLE_TIMEOUT_MS = 7 * 1000;
const MAX_IDLE_SKIPS = 3;
const turnEvents = new EventEmitter();
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
const idleTimers = new Map(); // roverId -> Timeout
const idleSkips = new Map(); // roverId -> Map(socketId -> count)
const idleDisarmed = new Map(); // roverId -> boolean, true once driver has acted this turn
function driverAdded(roverId, socketId, force) {
const queue = ensureQueue(roverId);
@@ -23,8 +30,18 @@ function driverRemoved(roverId, socketId) {
const queue = driverQueues.get(roverId);
if (!queue) return;
queue.queue = queue.queue.filter((id) => id !== socketId);
const skips = idleSkips.get(roverId);
if (skips) {
skips.delete(socketId);
if (skips.size === 0) {
idleSkips.delete(roverId);
}
}
idleDisarmed.delete(roverId);
if (queue.current === socketId) {
advanceTurn(roverId);
} else {
scheduleIdleTimer(roverId);
}
}
@@ -33,8 +50,14 @@ function cleanupRover(roverId) {
if (queue) {
clearTimeout(queue.timer);
}
clearTimeout(idleTimers.get(roverId));
driverQueues.delete(roverId);
activeDrivers.delete(roverId);
turnDeadlines.delete(roverId);
idleDeadlines.delete(roverId);
idleTimers.delete(roverId);
idleSkips.delete(roverId);
idleDisarmed.delete(roverId);
}
function canDrive(roverId, socket) {
@@ -60,20 +83,94 @@ function syncState(roverId) {
queue.current = queue.queue[0] || null;
setActiveDriver(roverId, queue.current);
clearTimeout(queue.timer);
turnDeadlines.delete(roverId);
clearTimeout(idleTimers.get(roverId));
idleDeadlines.delete(roverId);
idleDisarmed.delete(roverId);
turnEvents.emit('queue', { roverId });
return;
}
if (!queue.current) {
queue.current = queue.queue[0];
}
setActiveDriver(roverId, queue.current);
idleDisarmed.set(roverId, false);
scheduleNextTurn(roverId);
scheduleIdleTimer(roverId);
turnEvents.emit('queue', { roverId });
}
function scheduleNextTurn(roverId) {
const queue = driverQueues.get(roverId);
if (!queue) return;
clearTimeout(queue.timer);
const deadline = Date.now() + TURN_DURATION_MS;
turnDeadlines.set(roverId, deadline);
queue.timer = setTimeout(() => advanceTurn(roverId), TURN_DURATION_MS);
turnEvents.emit('queue', { roverId });
}
function scheduleIdleTimer(roverId) {
const queue = driverQueues.get(roverId);
clearTimeout(idleTimers.get(roverId));
idleDeadlines.delete(roverId);
if (
!queue ||
getMode() !== MODES.TURNS ||
queue.queue.length <= 1 ||
!queue.current ||
idleDisarmed.get(roverId)
) {
turnEvents.emit('queue', { roverId });
return;
}
const deadline = Date.now() + IDLE_TIMEOUT_MS;
idleDeadlines.set(roverId, deadline);
idleTimers.set(
roverId,
setTimeout(() => handleIdleTimeout(roverId, queue.current), IDLE_TIMEOUT_MS),
);
turnEvents.emit('queue', { roverId });
}
function incrementSkip(roverId, socketId) {
if (!idleSkips.has(roverId)) {
idleSkips.set(roverId, new Map());
}
const map = idleSkips.get(roverId);
const next = (map.get(socketId) || 0) + 1;
map.set(socketId, next);
return next;
}
function handleIdleTimeout(roverId, expectedDriver) {
const queue = driverQueues.get(roverId);
if (!queue || queue.current !== expectedDriver) {
scheduleIdleTimer(roverId);
return;
}
// already acted this turn, ignore idle timeout
if (idleDisarmed.get(roverId)) {
scheduleIdleTimer(roverId);
return;
}
const skips = incrementSkip(roverId, expectedDriver);
stopRover(roverId);
if (skips >= MAX_IDLE_SKIPS) {
sendAlert({
color: COLORS.error,
title: 'Driver removed',
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
});
removeDriverCompletely(roverId, expectedDriver);
return;
}
sendAlert({
color: COLORS.warn,
title: 'Turn skipped',
message: `${expectedDriver} skipped on ${roverId} (idle ${skips}/${MAX_IDLE_SKIPS})`,
});
advanceTurn(roverId);
}
function advanceTurn(roverId) {
@@ -82,6 +179,12 @@ function advanceTurn(roverId) {
if (queue.queue.length === 0) {
clearTimeout(queue.timer);
setActiveDriver(roverId, null);
turnDeadlines.delete(roverId);
idleDeadlines.delete(roverId);
clearTimeout(idleTimers.get(roverId));
idleTimers.delete(roverId);
idleDisarmed.delete(roverId);
turnEvents.emit('queue', { roverId });
return;
}
const mode = getMode();
@@ -89,15 +192,23 @@ function advanceTurn(roverId) {
queue.current = queue.queue[0] || null;
setActiveDriver(roverId, queue.current);
clearTimeout(queue.timer);
turnDeadlines.delete(roverId);
idleDeadlines.delete(roverId);
clearTimeout(idleTimers.get(roverId));
idleTimers.delete(roverId);
idleDisarmed.delete(roverId);
turnEvents.emit('queue', { roverId });
return;
}
const idx = queue.queue.findIndex((id) => id === queue.current);
const nextIdx = idx === -1 ? 0 : (idx + 1) % queue.queue.length;
queue.current = queue.queue[nextIdx];
setActiveDriver(roverId, queue.current);
idleDisarmed.set(roverId, false);
sendAlert({ color: COLORS.info, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` });
stopRover(roverId);
scheduleNextTurn(roverId);
scheduleIdleTimer(roverId);
}
function setActiveDriver(roverId, socketId) {
@@ -117,6 +228,21 @@ function getActiveDrivers() {
return map;
}
function getTurnQueues() {
const mode = getMode();
const payload = {};
driverQueues.forEach((queue, roverId) => {
payload[roverId] = {
mode,
queue: Array.from(queue.queue),
current: queue.current,
deadline: turnDeadlines.get(roverId) || null,
idleDeadline: idleDeadlines.get(roverId) || null,
};
});
return payload;
}
function stopRover(roverId) {
try {
const { issueCommand } = require('./commandService');
@@ -127,6 +253,24 @@ function stopRover(roverId) {
}
}
function removeDriverCompletely(roverId, socketId) {
try {
const assignmentService = require('./assignmentService');
assignmentService.forceRelease(roverId, socketId);
} catch (err) {
// best effort; log elsewhere if needed
}
}
function recordActivity(roverId, socketId) {
const queue = driverQueues.get(roverId);
if (!queue || queue.current !== socketId) return;
idleDisarmed.set(roverId, true);
clearTimeout(idleTimers.get(roverId));
idleDeadlines.delete(roverId);
turnEvents.emit('queue', { roverId, reason: 'activity' });
}
modeEvents.on('change', (mode) => {
driverQueues.forEach((_, roverId) => syncState(roverId));
});
@@ -138,4 +282,6 @@ module.exports = {
canDrive,
getActiveDrivers,
turnEvents,
getTurnQueues,
recordActivity,
};
+60 -7
View File
@@ -5,6 +5,55 @@ const videoSessions = require('./videoSessions');
const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const roverManager = require('./roverManager');
const { loadConfig } = require('../helpers/configLoader');
const config = loadConfig();
const mediaConfig = config.media || {};
function getPathPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
return parsed.pathname || '';
} catch {
return base.replace(/^[^/]*:\/\//, '').replace(/^[^/]+/, '');
}
}
const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, '');
const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : [];
function extractStreamInfo(path) {
const segments = (path || '').split('/').filter(Boolean);
if (!segments.length) {
return null;
}
let start = 0;
if (
whepPrefixSegments.length &&
whepPrefixSegments.every((segment, idx) => segments[idx] === segment)
) {
start = whepPrefixSegments.length;
}
let end = segments.length;
if (segments[end - 1] === 'whep') {
end -= 1;
}
const remaining = segments.slice(start, end);
if (remaining.length === 1) {
const rawId = remaining[0] || '';
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
return { type: 'rover', id: rawId, baseId };
}
if (remaining.length === 2 && remaining[0] === 'room') {
return { type: 'room', id: remaining[1] || '' };
}
return null;
}
function canView(socket) {
const mode = getMode();
@@ -15,7 +64,8 @@ function canView(socket) {
return isLockdownAdmin(socket);
}
if (mode === MODES.ADMIN) {
return isAdmin(socket);
const role = getRole(socket);
return role === 'spectator' || isAdmin(socket);
}
return true;
}
@@ -24,16 +74,18 @@ app.post('/mediamtx/auth', (req, res) => {
const body = req.body || {};
const path = (body.path || '').replace(/^\//, '');
const sessionId = body.user;
const roverId = path;
const streamInfo = extractStreamInfo(path);
if (!sessionId || !roverId) {
logger.warn('auth missing session or rover (session=%s path=%s)', sessionId, path);
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo });
if (!sessionId || !streamInfo?.id) {
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
return res.status(401).end();
}
const info = videoSessions.getSession(sessionId);
if (!info || info.roverId !== roverId) {
logger.warn('invalid session %s for rover %s', sessionId, roverId);
if (!info || info.sourceType !== streamInfo.type || info.sourceId !== streamInfo.id) {
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
return res.status(401).end();
}
const socket = io.sockets.sockets.get(info.socketId);
@@ -45,7 +97,8 @@ app.post('/mediamtx/auth', (req, res) => {
return res.status(401).end();
}
const role = getRole(socket);
if (role !== 'spectator' && !isAdmin(socket)) {
if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) {
const roverId = streamInfo.baseId || streamInfo.id;
if (!roverManager.isDriver(roverId, socket)) {
return res.status(401).end();
}
+12 -3
View File
@@ -1,12 +1,21 @@
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const sessions = new Map(); // sessionId -> { socketId, roverId }
const sessions = new Map(); // sessionId -> { socketId, sourceType, sourceId }
const socketSessions = new Map(); // socketId -> Set(sessionId)
function createSession(socket, roverId) {
function validateSource(source = {}) {
const { type, id } = source;
if (!type || !id) {
throw new Error('Invalid video source');
}
return { type, id };
}
function createSession(socket, source) {
const { type, id } = validateSource(source);
const sessionId = uuidv4();
sessions.set(sessionId, { socketId: socket.id, roverId });
sessions.set(sessionId, { socketId: socket.id, sourceType: type, sourceId: id });
if (!socketSessions.has(socket.id)) {
socketSessions.set(socket.id, new Set());
}
+66 -22
View File
@@ -9,29 +9,47 @@ const { loadConfig } = require('../helpers/configLoader');
const config = loadConfig();
const mediaConfig = config.media || {};
function buildWhepUrl(roverId) {
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) {
return '';
}
let origin;
let prefix = base;
try {
const parsed = new URL(base);
origin = parsed.origin;
prefix = `${parsed.origin}${parsed.pathname}`;
} catch (err) {
origin = base.replace(/\/.*$/, '');
// leave prefix as-is when URL parsing fails; fall back to string cleanup below
}
const cleanBase = origin.replace(/\/$/, '');
const encodedId = encodeURIComponent(roverId);
return `${cleanBase}/${encodedId}/whep`;
return prefix.replace(/\/+$/, '');
}
function canView(socket, roverId) {
const mode = getMode();
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return false;
function buildWhepUrlForSource(source) {
const cleanBase = getMediaPrefix();
if (!cleanBase) return '';
const segments = [];
if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id));
} else {
segments.push(encodeURIComponent(source.id));
}
if (mode === MODES.ADMIN && !isAdmin(socket)) {
return `${cleanBase}/${segments.join('/')}/whep`;
}
function passesMode(socket) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
return isLockdownAdmin(socket);
}
if (mode === MODES.ADMIN) {
const role = getRole(socket);
return role === 'spectator' || isAdmin(socket);
}
return true;
}
function canViewRover(socket, roverId) {
if (!passesMode(socket)) {
return false;
}
const role = getRole(socket);
@@ -41,24 +59,50 @@ function canView(socket, roverId) {
return roverManager.isDriver(roverId, socket);
}
io.on('connection', (socket) => {
socket.on('video:request', ({ roverId } = {}, cb = () => {}) => {
try {
if (!roverId) {
throw new Error('roverId required');
function canViewRoomCamera(socket) {
return passesMode(socket);
}
function normalizeRequest(payload = {}) {
if (!payload) return null;
if (payload.type && payload.id) {
return { type: payload.type, id: String(payload.id) };
}
if (!roverManager.rovers.has(roverId)) {
if (payload.roverId) {
return { type: 'rover', id: String(payload.roverId) };
}
if (payload.roomCameraId) {
return { type: 'room', id: String(payload.roomCameraId) };
}
return null;
}
io.on('connection', (socket) => {
socket.on('video:request', (payload = {}, cb = () => {}) => {
try {
const target = normalizeRequest(payload);
if (!target) {
throw new Error('video source required');
}
if (target.type === 'rover') {
const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id;
if (!roverManager.rovers.has(baseId)) {
throw new Error('Rover offline');
}
if (!canView(socket, roverId)) {
if (!canViewRover(socket, baseId)) {
throw new Error('Not authorized for video');
}
const url = buildWhepUrl(roverId);
} else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed');
} else {
throw new Error('Unsupported video source');
}
const url = buildWhepUrlForSource(target);
if (!url) {
throw new Error('Server video base URL missing');
}
const sessionId = videoSessions.createSession(socket, roverId);
cb({ url, token: sessionId });
const sessionId = videoSessions.createSession(socket, target);
cb({ url, token: sessionId, type: target.type, id: target.id });
} catch (err) {
logger.warn('video request failed: %s', err.message);
cb({ error: err.message });
+8 -2
View File
@@ -2,9 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
</head>
<body>
<div id="root"></div>
+10
View File
@@ -10,6 +10,7 @@
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-joystick-component": "^6.2.1",
"react-router-dom": "^7.9.6"
},
@@ -3391,6 +3392,15 @@
"react": "^19.2.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-joystick-component": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/react-joystick-component/-/react-joystick-component-6.2.1.tgz",
+2 -1
View File
@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
@@ -12,6 +12,7 @@
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-joystick-component": "^6.2.1",
"react-router-dom": "^7.9.6"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+18
View File
@@ -0,0 +1,18 @@
{
"name": "Multi Roomba Rover",
"short_name": "MRR",
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#020617",
"icons": [
{
"src": "/bitmap.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+75 -37
View File
@@ -3,8 +3,12 @@ import TelemetryPanel from './components/TelemetryPanel.jsx';
import DrivePanel from './components/DrivePanel.jsx';
import AlertFeed from './components/AlertFeed.jsx';
import AdminPanel from './components/AdminPanel.jsx';
import MobileControls, { MobileJoystick, AuxMotorControls } from './components/MobileControls.jsx';
import { DriveControlProvider } from './context/DriveControlContext.jsx';
import MobileControls, {
MobileLandscapeAuxColumn,
MobileLandscapeControlColumn,
} from './components/MobileControls.jsx';
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
import { SettingsProvider } from './settings/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx';
import AuthPanel from './components/AuthPanel.jsx';
@@ -12,6 +16,12 @@ import DriverVideoPanel from './components/DriverVideoPanel.jsx';
import RightPaneTabs from './components/RightPaneTabs.jsx';
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
import SessionSnapshot from './components/SessionSnapshot.jsx';
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
import TurnAlertListener from './components/TurnAlertListener.jsx';
import UserListPanel from './components/UserListPanel.jsx';
import ChatPanel from './components/ChatPanel.jsx';
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
@@ -45,30 +55,40 @@ function useLayoutMode() {
function DesktopLayout({ layout }) {
return (
<div className="flex flex-col gap-1">
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-1">
<div className="flex h-full gap-0.5 overflow-hidden">
<div className="flex min-w-0 flex-[1.8] flex-col gap-0.5 overflow-y-auto pr-0.5">
<DriverVideoPanel />
<RightPaneTabs layout={layout} />
</section>
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
{/* <AdminPanel /> */}
<div className="grid h-52 grid-cols-2 gap-0.5">
<div className="h-full min-h-0">
<UserListPanel fillHeight />
</div>
<div className="h-full min-h-0">
<ChatPanel fillHeight />
</div>
</div>
<LogPanel />
<SessionSnapshot />
</section>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
<RightPaneTabs layout={layout} />
{/* <SessionSnapshot /> */}
</div>
</div>
);
}
function MobilePortraitLayout() {
return (
<div className="flex flex-col gap-1">
<DriverVideoPanel />
<div className="flex flex-col gap-0.5">
<DriverVideoPanel layoutFormat='mobile'/>
<MobileControls />
<ChatPanel />
<UserListPanel />
<DrivePanel />
<TelemetryPanel />
<AuthPanel />
<AdminPanel />
<RoomCameraPanel />
<RoomCameraPanel panelId="mobile-portrait-room" />
<HomeAssistantControls />
<LogPanel />
</div>
);
@@ -76,48 +96,66 @@ function MobilePortraitLayout() {
function MobileLandscapeLayout() {
return (
<div className="flex flex-col gap-1">
<section className="grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
<div className="flex flex-col gap-1">
<AuxMotorControls />
</div>
<DriverVideoPanel />
<div className="flex flex-col gap-1">
<MobileJoystick />
</div>
</section>
<RoomCameraPanel />
<DrivePanel />
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
<div className="flex flex-col gap-1">
<AuthPanel />
<AdminPanel />
</div>
<div className="flex flex-col gap-1">
<LogPanel />
<div className="flex flex-col gap-0.5">
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
<MobileLandscapeAuxColumn />
<div>
<DriverVideoPanel layoutFormat='mobile'/>
<TelemetryPanel />
</div>
<MobileLandscapeControlColumn />
</section>
<div className="flex flex-col gap-0.5 pb-0.5">
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
<div className="flex flex-col gap-0.5">
{/* <AuthPanel /> */}
{/* <AdminPanel /> */}
<UserListPanel />
</div>
<div className="flex flex-col gap-0.5">
{/* <LogPanel /> */}
<ChatPanel />
</div>
</section>
<RoomCameraPanel panelId="mobile-landscape-room" />
<HomeAssistantControls />
{/* <DrivePanel /> */}
</div>
</div>
);
}
function App() {
const layout = useLayoutMode();
const isDesktop = layout === 'desktop';
const { visible: fullscreenVisible, mode: fullscreenMode, enterFullscreen, dismiss } = useFullscreenPrompt(layout);
const renderedLayout =
layout === 'desktop'
isDesktop
? <DesktopLayout layout={layout} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout />
: <MobilePortraitLayout />;
return (
<div className="min-h-screen bg-black text-slate-50">
<DriveControlProvider>
<main className="flex w-full flex-col gap-1 px-1 py-1 text-base">{renderedLayout}</main>
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
<SettingsProvider>
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
{renderedLayout}
</main>
<AlertFeed />
<TurnAlertListener />
<ModeGateOverlay />
</DriveControlProvider>
<FullscreenPrompt
visible={fullscreenVisible}
mode={fullscreenMode}
onEnterFullscreen={enterFullscreen}
onDismiss={dismiss}
/>
</ControlSystemProvider>
</SettingsProvider>
</div>
);
}
Binary file not shown.
Binary file not shown.
+12 -24
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import RoverRoster from './RoverRoster.jsx';
const MODES = [
{ key: 'open', label: 'Open' },
@@ -57,14 +58,10 @@ export default function AdminPanel() {
if (!isAdmin) return null;
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="flex items-center justify-between gap-1 text-sm">
<section className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between gap-0.5 text-sm">
<span>Admin controls</span>
<select
value={currentMode}
onChange={handleModeChange}
className="rounded-sm bg-black/40 px-1 py-1 text-sm text-slate-100"
>
<select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
{MODES.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.label}
@@ -73,32 +70,23 @@ export default function AdminPanel() {
</select>
</div>
<div className="mt-1 space-y-1 text-sm">
{roster.map((rover) => (
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
<div>
<p className="text-slate-100">{rover.name}</p>
<p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
</div>
<div className="flex flex-wrap gap-1 text-xs">
<RoverRoster
roster={roster}
renderActions={(rover) => (
<div className="flex flex-wrap gap-0.5 text-xs">
<button
type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
className="button-dark"
>
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button>
<button
type="button"
onClick={() => handleForceControl(rover.id)}
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
>
<button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
Force
</button>
</div>
</div>
))}
</div>
)}
/>
</section>
);
}
-76
View File
@@ -1,76 +0,0 @@
import { useMemo } from 'react';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import AuthPanel from './AuthPanel.jsx';
import AdminPanel from './AdminPanel.jsx';
const manualTabs = [
{ key: 'start', label: 'Start OI' },
{ key: 'safe', label: 'Safe' },
{ key: 'full', label: 'Full' },
{ key: 'passive', label: 'Passive' },
{ key: 'dock', label: 'Dock' },
];
export default function AdvancedSettings() {
const { roverId, sendOiCommand } = useDriveControl();
const socket = useSocket();
const canControl = Boolean(roverId);
const sensorButtons = useMemo(
() => [
{ key: 'start', label: 'Enable stream', enable: true },
{ key: 'stop', label: 'Disable stream', enable: false },
],
[],
);
const handleSensorToggle = (enable) => {
if (!roverId) return;
socket.emit('command', {
roverId,
type: 'sensorStream',
data: { sensorStream: { enable } },
});
};
return (
<div className="space-y-1">
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<p className="text-xs text-slate-400">Manual OI commands</p>
<div className="mt-1 flex flex-wrap gap-1">
{manualTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => sendOiCommand(tab.key)}
disabled={!canControl}
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-30"
>
{tab.label}
</button>
))}
</div>
</section>
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<p className="text-xs text-slate-400">Sensor stream</p>
<div className="mt-1 flex gap-1">
{sensorButtons.map((btn) => (
<button
key={btn.key}
type="button"
onClick={() => handleSensorToggle(btn.enable)}
disabled={!canControl}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
>
{btn.label}
</button>
))}
</div>
{!canControl && <p className="mt-1 text-xs text-slate-500">Assign a rover to toggle streams.</p>}
</section>
<AuthPanel />
<AdminPanel />
</div>
);
}
+3 -3
View File
@@ -30,7 +30,7 @@ export default function AlertFeed() {
if (!visible.length) return null;
return (
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1">
<div className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5">
{visible.map((toast) => (
<AlertToast key={toast.key} alert={toast.alert} />
))}
@@ -46,8 +46,8 @@ function AlertToast({ alert }) {
? 'bg-amber-500/30 text-amber-100'
: 'bg-emerald-500/30 text-emerald-100';
return (
<div className={`pointer-events-auto rounded-sm px-1 py-1 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
<div className={`pointer-events-auto px-0.5 py-0.5 text-[0.85rem] ${colorClasses}`}>
<p className="text-xs text-slate-200">{alert.title || 'Alert'}</p>
<p className="text-sm text-white">{alert.message}</p>
</div>
);
+9 -21
View File
@@ -22,46 +22,34 @@ export default function AuthPanel() {
}
return (
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="panel-section space-y-0.5 text-base">
<p className="text-sm text-slate-400">Admin login</p>
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
<form className="flex flex-col gap-0.5" onSubmit={handleLogin}>
<input
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
className="field-input"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
className="field-input"
placeholder="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="submit"
disabled={loading}
className="rounded-sm bg-slate-200 px-1 py-1 text-sm font-semibold text-black disabled:opacity-50"
>
<button type="submit" disabled={loading} className="button-dark w-full disabled:opacity-50">
{loading ? 'Logging in…' : 'Login'}
</button>
</form>
<div className="mt-1 flex gap-1 text-sm">
<button
type="button"
onClick={() => setRole('user')}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
{/* <div className="flex gap-0.5 text-sm">
<button type="button" onClick={() => setRole('user')} className="flex-1 button-dark">
Driver
</button>
<button
type="button"
onClick={() => setRole('spectator')}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
<button type="button" onClick={() => setRole('spectator')} className="flex-1 button-dark">
Spectator
</button>
</div>
</div> */}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react';
import { useControlSystem } from '../controls/index.js';
const SLIDER_THROTTLE_MS = 150;
function formatDegrees(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
}
export default function CameraServoPanel() {
const {
state: { roverId, camera },
actions: { setServoAngle, nudgeServo, goServoHome },
} = useControlSystem();
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
typeof camera?.angle === 'number'
? camera.angle
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
if (!enabled) return null;
const [pendingAngle, setPendingAngle] = useState(value);
const throttleRef = useRef(null);
const draggingRef = useRef(false);
useEffect(() => {
if (!draggingRef.current) {
setPendingAngle(value);
}
}, [value]);
useEffect(
() => () => {
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
},
[],
);
const step =
typeof config?.nudgeDegrees === 'number' && config.nudgeDegrees > 0
? config.nudgeDegrees
: 1;
const scheduleSend = (next) => {
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
throttleRef.current = setTimeout(() => {
setServoAngle(next);
}, SLIDER_THROTTLE_MS);
};
const handleSlider = (event) => {
const next = Number.parseFloat(event.target.value);
if (Number.isNaN(next)) return;
draggingRef.current = true;
setPendingAngle(next);
scheduleSend(next);
};
const commitSlider = () => {
draggingRef.current = false;
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
setServoAngle(pendingAngle);
};
const handleNudge = (direction) => {
const delta = step * direction;
nudgeServo(delta);
};
return (
<section className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-300">
<span>Camera Tilt</span>
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
</div>
<div>
<input
type="range"
className="w-full accent-emerald-400"
min={min}
max={max}
step={0.5}
value={pendingAngle}
onChange={handleSlider}
onMouseUp={commitSlider}
onTouchEnd={commitSlider}
onPointerUp={commitSlider}
/>
<div className="mt-0.5 flex justify-between text-xs text-slate-400">
<span>{formatDegrees(min)}</span>
<span>{formatDegrees(max)}</span>
</div>
</div>
{/* <div className="flex gap-0.5 text-sm">
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
Tilt Down
</button>
<button type="button" className="flex-1 button-dark" onClick={() => goServoHome()}>
Center
</button>
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(1)}>
Tilt Up
</button>
</div> */}
{/* <p className="text-xs text-slate-400">
Step: {formatDegrees(step)} · Pin GPIO {config?.pin}
</p> */}
</section>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
function roleColors(role, fromDiscord) {
if (fromDiscord) return 'text-indigo-200';
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatTime(ts) {
const date = new Date(ts);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function displayName(message) {
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
}
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
const { session } = useSession();
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(false);
const [engine, setEngine] = useState('flite');
const [voice, setVoice] = useState('rms');
const [pitch, setPitch] = useState(50);
const canChat = session?.role !== 'spectator';
const listRef = useRef(null);
const currentRoverId = session?.assignment?.roverId || null;
const rover = useMemo(
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, session?.roster],
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const sorted = useMemo(() => messages.slice(-200), [messages]);
useEffect(() => {
if (!listRef.current) return;
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [sorted]);
useEffect(() => {
if (ttsSupported) {
setSpeak(true);
} else {
setSpeak(false);
}
}, [ttsSupported]);
const ttsPayload = useMemo(() => {
if (!ttsSupported || !speak) return null;
if (engine === 'espeak') {
return { speak: true, engine, pitch };
}
return { speak: true, engine, voice };
}, [engine, pitch, speak, ttsSupported, voice]);
async function handleSend(event) {
event.preventDefault();
if (!canChat || hideInput) return;
// Allow users to type "\n" to represent a newline in messages
const normalizedDraft = draft.replace(/\\n/g, '\n');
const clean = normalizedDraft.trim();
if (!clean) return;
setSending(true);
try {
await sendMessage(clean, ttsPayload);
setDraft('');
blurChat();
} catch (err) {
alert(err.message);
} finally {
setSending(false);
}
}
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
return (
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
<div className={`surface overflow-y-auto space-y-0.25 ${listClass}`} ref={listRef}>
{sorted.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p>
) : (
sorted.map((msg) => {
const isAdmin =
msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin';
return (
<div
key={msg.id}
className={`surface-muted text-sm flex items-start gap-1 ${
isAdmin
? 'border border-amber-400/30'
: msg.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
<span className="text-[0.75rem] text-slate-400">{formatTime(msg.ts)}</span>
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role, msg.fromDiscord)}`}>
{displayName(msg)}
</span>
{msg.fromDiscord && (
<span className="rounded bg-indigo-500/30 px-1 text-[0.7rem] text-indigo-100">
Discord
</span>
)}
{msg.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {msg.roverId}</span>
)}
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{msg.text}</span>
</div>
);
})
)}
</div>
{!hideInput && (
<form className="flex gap-0.5" onSubmit={handleSend}>
<input
className="field-input flex-1"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onFocus={onInputFocus}
onBlur={onInputBlur}
ref={(el) => registerInputRef(el)}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat}
/>
{ttsSupported && (
<div className="flex items-center gap-0.5">
<label className="flex items-center gap-0.25 text-xs text-slate-300">
<input
type="checkbox"
checked={speak}
onChange={(e) => setSpeak(e.target.checked)}
className="accent-cyan-500"
/>
<span>Speak</span>
</label>
<select
value={engine}
onChange={(e) => setEngine(e.target.value)}
className="field-input text-xs"
>
<option value="flite">flite</option>
<option value="espeak">espeak</option>
</select>
{engine === 'flite' ? (
<select
value={voice}
onChange={(e) => setVoice(e.target.value)}
className="field-input text-xs"
>
{FLITE_VOICES.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
) : (
<select
value={pitch}
onChange={(e) => setPitch(Number(e.target.value))}
className="field-input text-xs"
>
{ESPEAK_PITCHES.map((p) => (
<option key={p} value={p}>
pitch {p}
</option>
))}
</select>
)}
</div>
)}
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
{sending ? '...' : 'Send'}
</button>
</form>
)}
</section>
);
}
@@ -0,0 +1,23 @@
import { useSession } from "../context/SessionContext";
import { FaDiscord } from "react-icons/fa";
export default function DiscordInviteButton({text = "Join our Discord!"}) {
const { session } = useSession();
const discordInvite = session?.discord?.invite || null;
if (!discordInvite) return null;
return (
<a
href={discordInvite}
target="_blank"
rel="noopener noreferrer"npm
className="inline-flex items-center px-3 py-2 text-white rainbow-animate-bg transition justify-center"
// animated rainbow backgound
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
>
<FaDiscord className="mr-2" />
{text}
</a>
);
}
+38 -17
View File
@@ -1,8 +1,12 @@
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useControlSystem } from '../controls/index.js';
import DiscordInviteButton from './DiscordInviteButton.jsx';
export default function DrivePanel() {
const { roverId, runStartDockFull, seekDock } = useDriveControl();
const {
state: { roverId },
actions,
} = useControlSystem();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
@@ -11,61 +15,78 @@ export default function DrivePanel() {
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
);
const handleStartDrive = () => {
if (!roverId) return;
actions.setMode('drive');
actions.runMacro('drive-sequence');
};
const handleDock = () => {
if (!roverId) return;
actions.setMode('dock');
actions.runMacro('seek-dock');
};
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="flex items-center justify-between text-sm text-slate-300">
<section className="panel-section space-y-0.5 text-base">
{/* <div className="flex items-center justify-between text-sm text-slate-300">
<span>Drive control</span>
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
</div>
<div className="mt-1 space-y-1">
<span>{roverId ? 'Ready to drive' : 'No rover assigned'}</span>
</div> */}
<div className="space-y-0.5">
<ActionCard
title="Start Driving"
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
description="Press to enable driving mode, then start moving."
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]}
tone="emerald"
onClick={runStartDockFull}
onClick={handleStartDrive}
disabled={!roverId}
/>
<ActionCard
title="Dock and Charge"
description="Line the rover up about a foot from the dock, then trigger an automatic approach."
description="Line the rover up about a foot from the dock, then trigger an automatic docking attempt."
statuses={[
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
]}
tone="indigo"
onClick={seekDock}
onClick={handleDock}
disabled={!roverId}
/>
</div>
{/* <DiscordInviteButton /> */}
</section>
);
}
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
const baseColor = tone === 'indigo' ? 'bg-indigo-600' : 'bg-emerald-600';
const colors =
tone === 'indigo'
? { base: 'bg-indigo-600', hover: 'hover:bg-indigo-500' }
: { base: 'bg-emerald-600', hover: 'hover:bg-emerald-500' };
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`w-full rounded-lg ${baseColor} px-1 py-1 text-left text-white disabled:opacity-40`}
className={`w-full ${colors.base} ${colors.hover} px-0.5 py-0.5 text-left text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-center content-center`}
>
<p className="text-base font-semibold">{title}</p>
<p className="text-sm text-white/90">{description}</p>
<div className="mt-1 flex flex-wrap gap-1">
{/* center statuses in button */}
<div className="mt-0.5 flex flex-wrap gap-0.5 items-center w-full justify-center">
{statuses.map((status) => (
<span
key={status.label}
className={`rounded-full px-1 py-0.5 text-xs font-semibold ${
status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100'
className={`p-1 text-xs font-semibold ${
status.active ? 'bg-green-600 text-white' : 'bg-red-500 text-white'
}`}
>
{status.label}
</span>
))}
</div>
{footnote && <p className="mt-1 text-xs text-emerald-50/80">{footnote}</p>}
{footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>}
</button>
);
}

Some files were not shown because too many files have changed in this diff Show More