Compare commits

...
Author SHA1 Message Date
legop3 3af21846c8 the 2026-03-24 02:48:16 -04:00
legop3 e0167939d7 plam 2026-03-23 23:27:31 -04:00
legop3 ebb400adc4 warae 2026-03-23 03:55:13 -04:00
legop3 463bd89dd8 oips 2026-03-23 03:53:59 -04:00
legop3 e311e67fc9 rewreaer 2026-03-23 03:50:23 -04:00
legop3 20227cd0f8 colorcoding 2026-03-23 03:39:36 -04:00
legop3 ad1b6b48f8 always there fullscreen button 2026-03-23 02:31:59 -04:00
legop3 626b48f777 mobile swappy controls 2026-03-23 02:04:21 -04:00
legop3 f112f74db0 todoing 2026-03-21 01:56:30 -04:00
legop3 cc1e887705 wawea 2026-03-21 01:10:15 -04:00
legop3 81b0638d6f clipfix 2026-03-21 00:56:20 -04:00
legop3 aeb4b29b88 weaw 2026-03-21 00:52:20 -04:00
legop3 54f8684169 yippee!!! 2026-03-21 00:11:27 -04:00
legop3 36a6c07f95 wooo!!!! 2026-03-21 00:06:22 -04:00
legop3 d6da071fde yayy!!! 2026-03-20 23:56:47 -04:00
legop3 9b1251c16d yay! 2026-03-20 23:41:50 -04:00
legop3 e3b428f9ab whipwhep 2026-03-20 23:35:34 -04:00
legop3 cb97e73987 whipwhep 2026-03-20 23:12:09 -04:00
legop3 1b7885e74b whipwhep 2026-03-20 22:52:39 -04:00
legop3 4542c9e6ae whipwhep 2026-03-20 22:52:35 -04:00
legop3 a58685fa91 the removal... 2026-03-20 22:42:59 -04:00
legop3 140ebb5996 todoing 2026-03-20 22:32:23 -04:00
legop3 76c17abdb6 whipwhep 2026-03-20 22:29:54 -04:00
legop3 f364e6ac60 whipwhep 2026-03-20 22:09:59 -04:00
legop3 57532f53d0 whipwhep 2026-03-20 02:25:43 -04:00
legop3 38b76559f4 whipwhep 2026-03-20 02:07:38 -04:00
legop3 1eb1a33e88 whipwhep 2026-03-20 02:06:03 -04:00
legop3 ab9d388392 whipwhep 2026-03-20 02:03:25 -04:00
legop3 cc6e1fde56 whipwhep 2026-03-20 02:00:17 -04:00
legop3 e2d9a1759b whipwhep 2026-03-20 01:56:15 -04:00
legop3 dbfab6aa26 whipwhep 2026-03-20 01:52:45 -04:00
legop3 c729631104 whipwhep 2026-03-20 01:50:29 -04:00
legop3 7ad3dc7015 whipwhep 2026-03-20 01:43:06 -04:00
legop3 5b8704ccb3 gwah 2026-03-17 19:54:53 -04:00
legop3 73ef6261ed ound 2026-03-17 19:46:53 -04:00
legop3 93204ec1f1 wewa 2026-03-17 19:35:36 -04:00
legop3 618fd3e4de remove duration limits 2026-03-17 19:14:13 -04:00
legop3 f5d7d2d82c ui 2026-03-17 19:07:59 -04:00
legop3 7934e1faf5 fix socket size limit 2026-03-17 18:59:39 -04:00
legop3 f63db3fdd7 oddeo 2026-03-17 18:54:31 -04:00
legop3 0e4954fadf audioo 2026-03-17 18:33:27 -04:00
legop3 c44632ed4d error handler 2026-03-17 16:35:53 -04:00
legop3 9f1efc6b91 fix 2026-03-17 03:31:53 -04:00
legop3 ad664f2d32 fix 2026-03-17 03:22:47 -04:00
legop3 fec9782e3b fix 2026-03-17 03:18:02 -04:00
legop3 84eaab3c9c guh 2026-03-17 03:11:04 -04:00
legop3 aaa93847e1 audio 2026-03-16 21:30:56 -04:00
legop3 4aa53e8f80 promp 2026-03-16 01:07:31 -04:00
legop3 74a758ab7c promp 2026-03-15 23:59:23 -04:00
legop3 182a49903f ploo 2026-03-15 23:14:43 -04:00
legop3 941b9889c9 pifix 2026-03-15 23:11:00 -04:00
legop3 6d40ec070e guh 2026-03-15 23:08:05 -04:00
legop3 169ccdbcf8 guh 2026-03-15 23:05:30 -04:00
legop3 a2098f4dfc fix thing 2026-03-15 23:02:17 -04:00
legop3 3070153052 fix thing 2026-03-15 22:57:15 -04:00
legop3 167582b9b0 fix 2026-03-15 22:46:27 -04:00
legop3 95972af558 things 2026-03-15 22:43:05 -04:00
legop3 2ea222355f audioplan 2026-03-13 17:58:28 -04:00
legop3 529ae62ad0 fix ldadmins not getting vip 2026-03-05 03:14:45 -05:00
legop3 3c6de7c040 verifiction 2026-03-05 03:10:26 -05:00
legop3 3007bab54d todoing 2026-03-04 02:55:37 -05:00
legop3 38e23b0adf todoing 2026-03-04 01:21:55 -05:00
legop3 181fe436ff fix keybindingses 2026-03-04 01:21:37 -05:00
legop3 b7f5967c9f todoing 2026-03-04 00:59:49 -05:00
legop3 e53fb5cb92 newtodo 2026-03-03 17:44:49 -05:00
legop3 325fdc05ea Merge branch 'main' of https://github.com/legop3/MultiRoombaRover 2026-03-03 14:28:49 -05:00
legop3 c4532f5825 plannings 2026-03-03 14:26:47 -05:00
legop3 d952b7ad90 Revise tone and output rules in commentary system
Updated tone description and removed redundant rules.
2026-03-02 01:19:39 -05:00
legop3 13da673495 promp 2026-02-28 12:17:27 -05:00
legop3 30d0f0c686 skpi count 2026-02-28 11:56:25 -05:00
legop3 7a21a53ea7 promt 2026-02-28 11:45:19 -05:00
legop3 4fd7f15292 promt 2026-02-28 11:43:00 -05:00
legop3 1350d7c395 promt 2026-02-28 02:13:21 -05:00
legop3 632d474e61 remove goal 2026-02-28 01:16:08 -05:00
legop3 84a0b02c18 promp 2026-02-28 00:56:30 -05:00
legop3 a44d4aed03 SKIP 2026-02-28 00:48:50 -05:00
legop3 fafe167571 promp 2026-02-28 00:44:52 -05:00
legop3 e73b094c21 promp 2026-02-28 00:35:29 -05:00
legop3 23825d51d2 promp 2026-02-27 23:54:53 -05:00
legop3 3cf3228b6f titanfall 2026-02-27 23:39:23 -05:00
legop3 99b10f7310 promp 2026-02-27 23:22:32 -05:00
legop3 ae52dc8335 promp 2026-02-27 22:11:35 -05:00
legop3 7942e61a40 tempchange 2026-02-27 20:05:43 -05:00
legop3 82fe62b835 no blah 2026-02-27 19:14:38 -05:00
legop3 12ce7c1344 add community goal 2026-02-27 19:05:52 -05:00
legop3 469c52cf7f temp reduction 2026-02-27 09:56:52 -05:00
legop3 1ac6c17a8e promp 2026-02-27 02:53:00 -05:00
legop3 231245a9fd promp 2026-02-27 02:41:54 -05:00
legop3 e30f7272cd promp 2026-02-27 02:21:53 -05:00
legop3 08fc278494 promp 2026-02-27 01:33:56 -05:00
legop3 32f5cc29d8 prompt 2026-02-27 01:30:26 -05:00
legop3 985a2f966a cliff improvements 2026-02-27 00:43:45 -05:00
legop3 e9e75b65ef melodrama 2026-02-26 21:06:08 -05:00
legop3 bbd088f9e1 new stuf 2026-02-26 18:57:50 -05:00
legop3 4284014578 soup 2026-02-26 18:06:50 -05:00
legop3 d34c107b1a gwuh 2026-02-26 03:32:31 -05:00
legop3 5a575aeab4 newstufff 2026-02-26 02:58:45 -05:00
legop3 4d953f3cad promt 2026-02-26 02:38:23 -05:00
legop3 09c7da715e loosenmaybe 2026-02-26 02:05:46 -05:00
legop3 9d58983f59 failmodeling 2026-02-26 01:21:42 -05:00
legop3 7afc79add0 promp 2026-02-26 01:08:58 -05:00
legop3 5d3652468f prommpt 2026-02-26 00:46:15 -05:00
legop3 04bedc7e5c remove length limit 2026-02-26 00:25:35 -05:00
legop3 a687d191c1 promptong 2026-02-25 23:04:02 -05:00
legop3 0d3b3b0995 prompt 2026-02-25 17:59:40 -05:00
legop3 65094ea5dc prompt 2026-02-25 17:49:55 -05:00
legop3 643557afc6 promp 2026-02-25 17:43:38 -05:00
legop3 5a7ce8ae69 stat pills for llm status 2026-02-25 17:28:18 -05:00
legop3 7e233e48b2 promptings 2026-02-25 15:50:20 -05:00
legop3 0a65c43eb0 new indicators in web ui for llm stuff 2026-02-25 15:21:54 -05:00
legop3 30671332af newstuf 2026-02-25 13:33:03 -05:00
legop3 20e89839fe new chat style minotiring 2026-02-25 12:57:06 -05:00
legop3 18c0d0fc8d fix log spam 2026-02-25 12:49:33 -05:00
legop3 b22ba216db new system for chat and stuff 2026-02-25 12:47:17 -05:00
legop3 ad31749813 set frequency to 0 (instant skipped or not) 2026-02-25 05:19:42 -05:00
legop3 9e3bce6ec3 tweakes 2026-02-25 05:11:21 -05:00
legop3 6c3ae17aeb prompt 2026-02-25 04:46:51 -05:00
legop3 965a27f3f2 prompt 2026-02-25 04:35:51 -05:00
legop3 2198c64e00 prompt 2026-02-25 04:28:16 -05:00
legop3 1b871a9ad1 adjustment 2026-02-25 04:25:26 -05:00
legop3 27e36206c2 primpr 2026-02-25 04:02:57 -05:00
legop3 6312f19e3c prompt and wheel drops 2026-02-25 03:55:08 -05:00
legop3 112391cbd1 tell it to watch chat 2026-02-25 03:40:18 -05:00
legop3 1f05a7b79c fix formatting 2026-02-25 03:30:36 -05:00
legop3 c5907d8487 prompt 2026-02-25 03:29:54 -05:00
legop3 47f3e7e654 change rover bot to the overseer 2026-02-25 03:23:58 -05:00
legop3 679e901ec6 remove battery stuffs 2026-02-25 02:23:10 -05:00
legop3 4af0deb1dd prompt 2026-02-25 02:13:06 -05:00
legop3 d38503d19c remove prior bot messages from the info 2026-02-25 02:01:34 -05:00
legop3 f7170c6b49 prompt 2026-02-25 00:57:44 -05:00
legop3 62994d07cd prompt 2026-02-25 00:41:13 -05:00
legop3 0af84084c9 tell it to look at chat... 2026-02-25 00:12:30 -05:00
legop3 8c24c84e59 better temps and prompting 2026-02-25 00:08:58 -05:00
legop3 9647a71d49 wara 2026-02-24 23:59:12 -05:00
legop3 315ce61ef7 tweak 2026-02-24 23:44:54 -05:00
legop3 0f31a662ee tiny prompt 2026-02-24 23:42:32 -05:00
legop3 e44d0f7b93 modufy prompt 2026-02-24 23:33:02 -05:00
legop3 49fc19f044 some tweaks 2026-02-24 23:15:31 -05:00
legop3 bb55e90464 remove mentions of skip 2026-02-24 20:16:46 -05:00
legop3 93971f37fe add llm status panel 2026-02-24 20:13:19 -05:00
legop3 46de9b71f3 switch to ollama library 2026-02-24 19:37:38 -05:00
legop3 1d97756aef fixes nameses 2026-02-24 19:31:00 -05:00
legop3 42c885af28 local ollama commentary 2026-02-24 19:03:20 -05:00
legop3 da79a62db6 change some settings defaults 2026-02-21 19:05:13 -05:00
legop3 d3b35506d9 duck fade 2026-02-21 18:59:00 -05:00
legop3 12a787b7c6 guhw 2026-02-21 18:49:31 -05:00
legop3 bf7d54c4f9 brush ducking only ig 2026-02-21 18:45:59 -05:00
legop3 abd337f723 kjfdalshf 2026-02-21 18:20:02 -05:00
legop3 d3a17608af gologle 2026-02-21 18:15:37 -05:00
legop3 ed93bbf4d6 guh 2026-02-21 18:05:19 -05:00
legop3 d2904f067c ploo 2026-02-21 17:52:52 -05:00
legop3 a76062b873 gug 2026-02-21 17:32:16 -05:00
legop3 32ec8e1aa3 ploo 2026-02-21 17:22:38 -05:00
legop3 657189e979 wuh 2026-02-21 13:40:02 -05:00
legop3 cc92174d79 wa 2026-02-21 13:35:28 -05:00
legop3 eefe6f01bb gawa 2026-02-21 13:30:46 -05:00
legop3 4ccc7dfae6 audio debug 2026-02-21 13:21:44 -05:00
legop3 8313f5da66 chrome fix maybe? 2026-02-21 13:14:27 -05:00
legop3 d5978c5e3e agressive compressive 2026-02-18 22:21:41 -05:00
legop3 f1c6ab44ce maybe finally fix double audio 2026-02-18 22:02:32 -05:00
legop3 d165f69b6f fixes 2026-02-18 21:54:16 -05:00
legop3 438fff0902 volume stuff 2026-02-18 21:39:34 -05:00
legop3 7e772ab607 server reboot too 2026-02-18 15:13:38 -05:00
legop3 40e21d4ac5 add reboot command 2026-02-18 15:09:36 -05:00
legop3 eb52c8c6ac oopeies 2026-02-11 17:16:36 -05:00
legop3 1e1a6b6b01 new battery bars!!! 2026-02-11 17:05:30 -05:00
legop3 dcf7208c08 docs stub 2026-02-11 02:32:38 -05:00
legop3 0c3b3aa711 update colors of social buttons 2026-02-10 20:26:17 -05:00
legop3 42c99410f4 new socials buttons and stuffses 2026-02-10 20:02:55 -05:00
legop3 1ac46f4a03 switch back to css anim 2026-02-10 13:42:23 -05:00
legop3 8ea0f7be70 better side brush animation 2026-02-10 13:36:47 -05:00
legop3 0c992e2457 auto sidebrush 2026-02-10 13:18:29 -05:00
legop3 1842177a6d longer bumpening 2026-02-08 17:02:55 -05:00
legop3 c9a19d46f7 drive macro dock bumpoff 2026-02-08 16:59:25 -05:00
legop3 cf0c12cff7 map hud adjustment for mobile 2026-02-02 11:20:53 -05:00
legop3 b4c122d914 dock drive action and joystick tweaks 2026-01-31 22:00:09 -05:00
legop3 3cfb2683ef a few tweaks to dock drive action 2026-01-31 21:44:01 -05:00
legop3 cb5e5ae755 helpp 2026-01-31 21:26:09 -05:00
legop3 d3852dd86b longer horn allowance 2026-01-31 21:20:19 -05:00
legop3 7c1b290df3 swap stuff around because of how stupid safari is i hate apple i hate apple i hate apple 2026-01-31 21:01:23 -05:00
legop3 133b433255 more organized backend and better mobile control columns 2026-01-31 20:49:14 -05:00
legop3 d64e99b674 more adjustment 2026-01-31 16:30:50 -05:00
legop3 79da247dbe short tap fix 2026-01-31 16:29:36 -05:00
legop3 01cad4515f adjustmet 2026-01-31 16:25:59 -05:00
legop3 dc92785f19 gahwah 2026-01-31 16:22:45 -05:00
legop3 00d1edd11b horn cooldown adjustments 2026-01-31 16:16:26 -05:00
legop3 c24ae71e63 horn limits and such 2026-01-31 16:11:24 -05:00
legop3 92c9ae4834 sine and saw volumes 2026-01-31 15:45:36 -05:00
legop3 5916520b1a horn button light up 2026-01-31 15:37:46 -05:00
legop3 7eeb9d13e4 ui 2026-01-31 15:34:38 -05:00
legop3 3fdb1c05e5 ui improvements 2026-01-31 15:32:08 -05:00
legop3 090d9d6982 horn updates 2026-01-31 15:18:25 -05:00
legop3 b7d636c70a horn pass 2026-01-31 13:44:35 -05:00
legop3 92ab763b27 sawtooth wave 2026-01-31 12:30:21 -05:00
legop3 43dc3df901 hornn testingg 2026-01-31 12:23:53 -05:00
legop3 3997127e5e resize batt in sbs 2026-01-30 23:30:47 -05:00
legop3 c60cae75a4 poosh 2026-01-30 23:01:04 -05:00
legop3 4f6530badf just show em all, all the time 2026-01-30 22:58:10 -05:00
legop3 fd06f23de4 feex 2026-01-30 22:37:39 -05:00
legop3 ca3d95319d mini video prewarm :3 2026-01-30 22:31:40 -05:00
legop3 bdb4f41e93 local spectators get real video maybe if it works 2026-01-30 21:53:02 -05:00
legop3 a5f33c0a92 updated /mini again.. 2026-01-30 21:32:17 -05:00
legop3 a4d6f55576 remove updatedAt from UI 2026-01-29 13:25:58 -05:00
legop3 7216d9ba51 admin reason!! 2026-01-29 13:24:07 -05:00
legop3 be4c6260ae /mini simplifications (was too big) 2026-01-29 12:58:58 -05:00
legop3 5e94fc7283 /mini new (again) 2026-01-28 21:52:41 -05:00
legop3 bf05d07092 gamepad rate lmiit 2026-01-28 17:00:19 -05:00
legop3 c5ae78dcab finally... button.. 2026-01-28 14:36:47 -05:00
legop3 33d329e2de moving things 2026-01-28 14:32:09 -05:00
legop3 690f38972f even more betterer button 2026-01-28 14:28:52 -05:00
legop3 77b5484381 betterer button 2026-01-28 14:25:33 -05:00
legop3 ccf0eeb1aa better button 2026-01-28 14:22:29 -05:00
legop3 d87ec55802 gwuh 2026-01-28 14:16:06 -05:00
legop3 2cc0ce962f spam fix 2026-01-28 14:11:44 -05:00
legop3 d2722e40cc ui fixes 2026-01-28 14:09:09 -05:00
legop3 ae81f8914f ugh 2026-01-28 13:59:29 -05:00
legop3 7e850dab22 stateful night vision rework 2026-01-28 13:45:01 -05:00
legop3 f3cc73f178 light panel adjustments 2026-01-27 20:08:04 -05:00
legop3 eea1774549 color light support in HA panel 2026-01-27 19:29:06 -05:00
legop3 7dbb657dcd 5s autocharge to match ui... 2026-01-27 17:19:07 -05:00
legop3 48e4ddd090 ug 2026-01-26 19:53:19 -05:00
legop3 f7677a443a carphone 2026-01-26 19:49:45 -05:00
legop3 482e7c1e6b camera servo speed limit 2026-01-26 19:45:14 -05:00
legop3 b275d5a08c chat on spectator page 2026-01-26 14:10:13 -05:00
legop3 2062b4888c wawa 2026-01-26 01:03:09 -05:00
legop3 2671247347 tts passthrough for all in queue 2026-01-25 23:45:18 -05:00
legop3 3f34196418 better dockdrive info labels 2026-01-25 20:44:50 -05:00
legop3 689d914b4e gamepad ripout 2026-01-25 17:15:57 -05:00
legop3 b6b1bee1dd more tweakings 2026-01-25 03:18:50 -05:00
legop3 522ec335bf bar fit screen 2026-01-25 03:03:16 -05:00
legop3 b730c98704 taller bar, move user list 2026-01-25 03:01:16 -05:00
legop3 d7833dbba9 spectator tweakings 2026-01-25 02:59:16 -05:00
legop3 536e4fe32e new /mini stuff 2026-01-24 23:54:52 -05:00
legop3 2494f6e276 lockdown fixes 2026-01-24 23:40:22 -05:00
legop3 2dda137855 centered hud for your brain 2026-01-24 03:06:29 -05:00
legop3 8341328b51 round. . . ? 2026-01-24 02:58:36 -05:00
legop3 b718de1e88 better DRD (dead rover detection) 2026-01-23 16:46:09 -05:00
legop3 ec4e969340 disconnected reboot timer for pi 2026-01-23 16:42:13 -05:00
legop3 dd55514cb4 ws keepalive (brownout mitigation) 2026-01-23 16:32:20 -05:00
legop3 ba4cf80f41 admin logs for IP stuff 2026-01-23 02:59:25 -05:00
legop3 622cd2875c no more ips in session data 2026-01-23 02:16:37 -05:00
legop3 98ca2e0626 ip list (testing) 2026-01-22 22:48:31 -05:00
legop3 dbc5111c53 chat typing and classic typing beeps 2026-01-22 04:18:55 -05:00
legop3 c30325afc0 adjustmentos 2026-01-22 01:10:09 -05:00
legop3 12512b8856 new discord bot stuff 2026-01-21 23:19:46 -05:00
legop3 bb3197ca67 gsw 2026-01-21 14:47:00 -05:00
legop3 0b1551cabf wg 2026-01-21 14:43:47 -05:00
legop3 69198a5556 guh 2026-01-21 14:41:42 -05:00
legop3 f3a5e7bf77 bar fix 2026-01-21 14:35:46 -05:00
legop3 d2acd9f19a gwah 2026-01-21 14:31:12 -05:00
legop3 94c3939671 stupid 2026-01-21 14:27:29 -05:00
legop3 f7de6d52be wawa 2026-01-21 14:24:51 -05:00
legop3 fff07ffbe6 garbage 2026-01-21 14:16:41 -05:00
legop3 9c046cc3b1 wah 2026-01-21 14:11:12 -05:00
legop3 c465c03e34 useless 2026-01-21 14:08:42 -05:00
legop3 99a1c5418d gah 2026-01-21 14:04:14 -05:00
legop3 2fc9becc88 wag 2026-01-21 13:52:27 -05:00
legop3 fe94c38963 let song commands through 2026-01-21 13:47:29 -05:00
legop3 55c72e0d9b wawa 2026-01-21 13:44:10 -05:00
legop3 a237d32d58 gah 2026-01-21 13:40:07 -05:00
legop3 213630cb68 this better work 2026-01-21 13:36:01 -05:00
legop3 eb85b85b8f ugh 2026-01-21 13:28:51 -05:00
legop3 0600b8552c ugh 2026-01-21 13:18:32 -05:00
legop3 662efb300b wawa 2026-01-21 13:15:07 -05:00
legop3 e2f94720ec arrg 2026-01-21 13:13:08 -05:00
legop3 bcfb2946cf wawah 2026-01-21 13:06:32 -05:00
legop3 02603aa664 waw awa.. 2026-01-21 13:03:39 -05:00
legop3 c67c3dc761 oops 2026-01-21 12:58:01 -05:00
legop3 26d7335c9a ocl in hud 2026-01-21 12:53:07 -05:00
legop3 a99e6e5286 very fast recovery? 2026-01-21 12:40:38 -05:00
legop3 d93d78cb2b tuning 2026-01-21 12:38:38 -05:00
legop3 e01878dd4e ignoore 2026-01-21 12:27:09 -05:00
legop3 598f353fe5 now with Obscenity 2026-01-21 12:25:31 -05:00
legop3 7249621ebe ?ploooooo 2026-01-21 03:15:24 -05:00
legop3 6009056edd ?plooooo 2026-01-21 03:08:37 -05:00
legop3 fd7b858395 ?ploooo 2026-01-21 03:03:00 -05:00
legop3 1fd0de5b1e ?plooo 2026-01-21 03:01:29 -05:00
legop3 720a010973 ?ploo 2026-01-21 02:58:04 -05:00
legop3 2564a451b5 different maths 2026-01-21 02:53:18 -05:00
legop3 8326898403 output rate limit and override gating 2026-01-21 02:38:37 -05:00
legop3 e0edbcfb28 tweaking times 2026-01-21 02:25:28 -05:00
legop3 4f60248bc6 pairs 2026-01-21 02:23:43 -05:00
legop3 629d979b67 timing adjustments 2026-01-21 02:16:24 -05:00
legop3 59c19f1da9 overcurrent limiter testing 2026-01-21 02:12:54 -05:00
legop3 b9a007bd48 big ui sweep. combined queue and roster. 2026-01-20 14:25:48 -05:00
legop3 850ac3ec15 remove bad from telemetry panel 2026-01-20 01:26:32 -05:00
legop3 87dd9a24a0 moving things around 2026-01-20 01:18:13 -05:00
legop3 bdcb488988 the dreaded control gate... 2026-01-19 10:21:54 -05:00
legop3 9e6053d5c0 oops, camera slider 2026-01-19 03:05:17 -05:00
legop3 4d699e1c4c 4:3 UI switchover 2026-01-19 03:00:47 -05:00
legop3 5e7581c82d 640x480 2026-01-19 00:13:08 -05:00
legop3 f7e4269959 hardcode 1296:972 2026-01-18 23:43:56 -05:00
legop3 f3f1db1398 no more video env garbage 2026-01-18 23:08:17 -05:00
legop3 133c061ee9 update video publisher 2026-01-18 22:59:35 -05:00
legop3 6859a6c048 smaller alert scale 2026-01-18 16:19:43 -05:00
legop3 465bddb9dd larger labels 2026-01-18 16:17:25 -05:00
legop3 c7a639a568 /mini and chat improvements 2026-01-18 16:15:41 -05:00
legop3 df952b27e0 gouo 2026-01-18 13:05:32 -05:00
legop3 36f28a9708 add discord button to turns waiting HUD 2026-01-18 12:34:00 -05:00
legop3 2975d394b6 image size averages 2026-01-17 18:35:04 -05:00
legop3 befcf7ad5a ignore!! 2026-01-16 16:05:06 -05:00
legop3 27ae3bcd1e ugh 2026-01-16 16:04:48 -05:00
legop3 90fd0262c7 community goal system 2026-01-16 16:02:56 -05:00
legop3 64a9dd3583 removing chat filters 2026-01-16 02:52:54 -05:00
legop3 972942e497 no more double YOU 2026-01-15 18:56:35 -05:00
legop3 e34d838d41 better big button 2026-01-15 18:43:46 -05:00
legop3 705b38ea7b big loud turn indicators 2026-01-15 18:25:33 -05:00
legop3 f129449d5a tiny toasts! 2026-01-15 17:44:26 -05:00
legop3 d4ce6e9092 random food word nicknames! 2026-01-15 17:26:31 -05:00
legop3 2252527d89 tts fix for chat in hud 2026-01-15 14:47:32 -05:00
legop3 d184590bf2 new chat layout and stuff 2026-01-15 14:43:55 -05:00
legop3 5b9167ab2a give up for now 2026-01-15 12:23:03 -05:00
legop3 655fed4326 ugh 2026-01-15 12:15:51 -05:00
legop3 6c01087738 pigpiod is only on ipv6? 2026-01-15 12:06:22 -05:00
legop3 d272a33dc2 roverd race fix 2026-01-15 12:01:45 -05:00
legop3 8212a14ea8 websocket / polling toggle 2026-01-15 11:35:14 -05:00
legop3 9622129c73 conn status in admin panel 2026-01-15 03:02:55 -05:00
legop3 37927d82af chat history, better turn queue updates 2026-01-15 02:55:46 -05:00
legop3 23fe03ca54 camera 0 to drive macro 2026-01-14 20:15:20 -05:00
legop3 899e647a9d socket log in admin panel 2026-01-13 20:35:45 -05:00
legop3 cab4dc716d replay feedback 2026-01-13 20:28:33 -05:00
legop3 5caf26bb2d update ui layout 2026-01-13 20:24:00 -05:00
legop3 e666b4c195 health service and panel 2026-01-13 20:16:11 -05:00
legop3 c2ca951fe8 better camera snapshot service 2026-01-13 19:57:29 -05:00
legop3 399c077c7a replay panel tweaking 2026-01-13 19:24:25 -05:00
legop3 b8a4a18455 overwrite 2026-01-13 19:15:19 -05:00
legop3 3aedaa26c8 log 2026-01-13 19:12:18 -05:00
legop3 a6e7e683ba wawa 2026-01-13 19:08:51 -05:00
legop3 27c46b44b2 wuh 2026-01-13 19:01:25 -05:00
legop3 5fdcb233f8 rover snapshots in server 2026-01-13 18:54:30 -05:00
legop3 b7a79b03c9 ugh 2026-01-13 18:37:27 -05:00
legop3 fb00467106 g 2026-01-13 18:34:46 -05:00
legop3 32f9a2befc ugh 2026-01-13 18:24:43 -05:00
legop3 e10e2f9a0b update 2026-01-13 17:56:01 -05:00
legop3 8b0928f428 update room cam 2026-01-13 17:43:56 -05:00
legop3 f3e13f6ab7 lets see . . . 2026-01-13 17:21:32 -05:00
legop3 4ab6bad1b8 wah! 2026-01-13 02:16:22 -05:00
legop3 b4754b1c54 wwah 2026-01-13 02:10:21 -05:00
legop3 9aad7ff775 srt read bypass 2026-01-13 02:06:19 -05:00
legop3 c3a9ea7281 ugh 2026-01-13 02:03:57 -05:00
legop3 ba652005f5 auth bypass for local srt 2026-01-13 01:58:10 -05:00
legop3 a49a090026 change snapshot dir 2026-01-13 01:52:19 -05:00
legop3 c265121335 low quality preview for non-drivers 2026-01-13 01:45:15 -05:00
legop3 56b89b5dff rainbet 2026-01-12 19:22:49 -05:00
legop3 00f97c8005 update ignore 2026-01-12 01:22:34 -05:00
legop3 d8ce85be60 oops 2026-01-12 01:21:44 -05:00
legop3 3b67e81451 use webhooks 2026-01-12 01:19:58 -05:00
legop3 870fdc63be update chat message component 2026-01-12 00:26:40 -05:00
legop3 5ae0b0ca79 better discord chat bridging 2026-01-11 17:44:02 -05:00
legop3 778543dd5c autodock is useful after all 2026-01-09 15:16:00 -05:00
legop3 29154c56e6 idle autodock, alerts on spectator and mini pages 2026-01-09 15:06:44 -05:00
legop3 50d3977814 keyboard light controls 2026-01-08 23:44:36 -05:00
legop3 acebcd7307 new alert service colors 2026-01-08 22:44:31 -05:00
legop3 55782dabf0 dock bump protection improvements 2026-01-08 22:18:29 -05:00
legop3 79c118bc88 faster backup 2026-01-08 21:38:14 -05:00
legop3 173d6fffa7 dock bump prevention 2026-01-08 21:35:47 -05:00
legop3 cf072ef0e0 oooops... 2026-01-08 21:13:25 -05:00
legop3 30ebd05a97 topdownmod 2026-01-08 21:12:30 -05:00
legop3 d3f2f15370 updated mobile page 2026-01-08 20:32:47 -05:00
legop3 008cf96c7e dont fix it 2026-01-08 19:54:47 -05:00
legop3 981262a0ae ts alone only 2026-01-08 19:25:01 -05:00
legop3 b884f23b13 ts command :3 2026-01-07 23:06:55 -05:00
legop3 dc9f93ca60 made mobile HUD smaller 2026-01-07 22:46:17 -05:00
legop3 4005ced6f9 tweaks and padding fix 2026-01-07 21:57:57 -05:00
legop3 f7dd4f7b89 remove queue mentions from embed service 2026-01-07 21:50:53 -05:00
legop3 4b7c26a7b4 change to roomba rover 2026-01-07 21:32:46 -05:00
legop3 7335cefffb dynamic http embeds, kofi button, etc 2026-01-07 21:28:21 -05:00
legop3 aba01161c9 remove type badge from light controls 2026-01-06 20:56:27 -05:00
legop3 b4edf72c9a fix ffmpeg error 2026-01-04 13:14:32 -05:00
legop3 066f976cf4 replay overhaul 2026-01-04 13:08:23 -05:00
legop3 2f726035c9 video was too bigs 2026-01-03 23:26:47 -05:00
legop3 5b8fb30e71 adjust timings 2026-01-03 22:05:37 -05:00
legop3 eb4e1621c0 tpms 2026-01-03 21:58:08 -05:00
legop3 6d55073bfb replay in web UI 2026-01-03 21:38:19 -05:00
legop3 242fe461d9 replay use mp4 instead of gif 2026-01-03 21:19:07 -05:00
legop3 fcc71426ee oops 2026-01-03 21:11:27 -05:00
legop3 4e30eb1d52 pallete gen ffmpeg 2026-01-03 21:01:21 -05:00
legop3 e0cadb493f replay capture ? 2026-01-03 20:53:38 -05:00
legop3 bbb4014f61 everyone can status! 2026-01-03 20:02:49 -05:00
legop3 3c1c75a2ec change to ma 2026-01-03 15:15:45 -05:00
legop3 c4dda507e2 add server mode to bot status 2026-01-03 15:13:39 -05:00
legop3 56ed528412 rs status shortcut 2026-01-03 15:00:27 -05:00
legop3 258f244194 more bot stuff 2026-01-03 12:39:26 -05:00
legop3 ec0e1564d7 changed discord bot stuff 2026-01-03 12:29:47 -05:00
legop3 4f2907a105 monch 2025-12-30 13:13:56 -05:00
legop3 5829cd370c web ui fixes and tweaks 2025-12-29 18:27:15 -05:00
legop3 5ef6b640c6 turn up cam quality 2025-12-29 17:41:14 -05:00
legop3 ff638d84d2 the old fashioned bars are back 2025-12-28 00:47:18 -05:00
legop3 061d4d4689 bumping bumpers :3 2025-12-28 00:22:44 -05:00
legop3 3cc12b76b9 new top down stuff 2025-12-28 00:06:24 -05:00
legop3 1241c867a5 oops 2025-12-27 04:45:34 -05:00
legop3 8b3806bcd5 no more hud! (keep the telem stuff tho) 2025-12-27 04:45:22 -05:00
legop3 eeb4e65df7 tweak 2025-12-27 04:43:05 -05:00
legop3 b714bd0fed tweak 2025-12-27 04:41:02 -05:00
legop3 a989f2029a tweak 2025-12-27 04:39:07 -05:00
legop3 ca1f7ef54a tweak 2025-12-27 04:37:15 -05:00
legop3 87db66ac1f tweak 2025-12-27 04:27:14 -05:00
legop3 8da2196ee0 tweak 2025-12-27 04:20:26 -05:00
legop3 49d822d468 tweak 2025-12-27 04:14:33 -05:00
legop3 fd765ec1da tweak 2025-12-27 04:12:02 -05:00
legop3 086d3cdee2 tweak 2025-12-27 04:09:12 -05:00
legop3 83f6f7eac1 tweak 2025-12-27 04:05:44 -05:00
legop3 3df303bc09 tweak 2025-12-27 04:02:51 -05:00
legop3 94a779581b tweak 2025-12-27 03:52:42 -05:00
legop3 c87767d3ee tweak 2025-12-27 03:50:33 -05:00
legop3 6ca30adcaf tweak 2025-12-27 03:47:31 -05:00
legop3 588305184e tweak 2025-12-27 03:35:52 -05:00
legop3 293b73dc20 tweak 2025-12-27 03:26:56 -05:00
legop3 d4440a4f05 tweak 2025-12-27 03:24:16 -05:00
legop3 3349120c0d tweak 2025-12-26 23:27:37 -05:00
legop3 bd862bf3f2 tweak 2025-12-26 23:19:55 -05:00
legop3 1a6ca5ce73 tweak 2025-12-26 23:09:40 -05:00
legop3 9ea29f8e36 dock assist HUD 2025-12-26 23:05:25 -05:00
legop3 b9d7a62b0e new dock / drive / docking buttons and dialog 2025-12-26 20:04:21 -05:00
legop3 6301058f87 persist tts settings 2025-12-25 21:27:19 -05:00
legop3 8e1de78cc7 seek dock when disconnected for 1 minute 2025-12-24 17:06:27 -05:00
legop3 0b564c7ee7 battery manager fix and stop rovers when we should 2025-12-24 16:58:06 -05:00
legop3 195ecb7d61 spectator reconnects and stuff 2025-12-23 22:27:35 -05:00
legop3 108de883a0 hopefully fix battery locking 2025-12-22 20:14:03 -05:00
legop3 01df853592 fix audio player stuff 2025-12-22 19:47:48 -05:00
legop3 020ddfea07 keyboard camerea tilt slider 2025-12-22 18:55:19 -05:00
legop3 b4a5633bd4 wug 2025-12-22 01:03:37 -05:00
legop3 f579516d64 box my letter my 2025-12-22 01:01:41 -05:00
legop3 6f887b00f1 letter my box MORE 2025-12-22 00:56:16 -05:00
legop3 0ce6c05e05 letter my box 2025-12-22 00:50:51 -05:00
legop3 fe9f1c13e2 route for mini 2025-12-22 00:39:39 -05:00
legop3 e3874b6237 mini viewer!! 2025-12-22 00:37:47 -05:00
legop3 e0a64e828e plane 2025-12-21 23:51:46 -05:00
legop3 a8fb23287c maked it betters 2025-12-21 16:13:22 -05:00
legop3 ed9bb3216c top-down in spectator 2025-12-21 16:08:34 -05:00
legop3 1b013ed91b thing in HUD 2025-12-21 15:42:01 -05:00
legop3 8e01dcaabd git pull 2025-12-21 14:50:33 -05:00
legop3 0836afc0c0 wagh 2025-12-21 14:40:06 -05:00
legop3 8296278e1c giihlkjsadf 2025-12-21 14:31:57 -05:00
legop3 6028e275da leave me alone 2025-12-21 14:21:32 -05:00
legop3 314b88b146 leave me alone 2025-12-21 14:20:56 -05:00
legop3 de4295caf1 wuh 2025-12-21 14:08:09 -05:00
legop3 c5e66b3ec5 guh 2025-12-21 14:07:34 -05:00
legop3 aba5efcf40 adjust labels 2025-12-21 14:03:34 -05:00
legop3 e69fd18256 moveing stuffs arounds 2025-12-21 13:47:13 -05:00
legop3 0be105e9dd spin 2025-12-21 06:01:22 -05:00
legop3 a1fef27a59 ugh 2025-12-21 05:52:35 -05:00
legop3 77ec19a4d6 spinfix?urhfk 2025-12-21 05:48:43 -05:00
legop3 d4d6f0674d satureate my oiter 2025-12-21 05:33:21 -05:00
legop3 e334246cef 100 2025-12-21 05:29:58 -05:00
legop3 81a8c35873 hue my oiter 2025-12-21 05:28:00 -05:00
legop3 9c3dbfebcb top-down roomba view!!! finally!! 2025-12-21 05:20:46 -05:00
legop3 cd9e82938b faster sensors 2025-12-21 03:55:55 -05:00
legop3 0fb6ea49b3 no more room controls tab 2025-12-21 02:54:58 -05:00
legop3 a3dae83bf7 NO MORE PURPLEE 2025-12-21 02:38:21 -05:00
legop3 1bd486a6c0 no asterisks allowed in nicknames 2025-12-20 23:56:03 -05:00
legop3 b965f7b561 blueberries 2025-12-20 00:20:11 -05:00
legop3 45aef7339e let everyone on a rover use tts 2025-12-18 15:01:40 -05:00
legop3 fb7ecec17e puddle 2025-12-17 01:33:31 -05:00
legop3 7b29d741b0 ugh.. 2025-12-17 01:13:38 -05:00
legop3 67f4f014c4 ugh 2025-12-17 01:06:09 -05:00
legop3 c42d314cc9 woah 2025-12-17 00:53:55 -05:00
legop3 102192c631 fast 2025-12-17 00:19:23 -05:00
legop3 cfe9055cb7 tweaking sensor recovery 2025-12-16 22:31:21 -05:00
legop3 501bc234ee better sensor recovery 2025-12-12 22:50:04 -05:00
legop3 c07e5971f5 auto sensor restart 2025-12-12 22:45:19 -05:00
legop3 bbdecc324a stop people from switching to locked rovers... 2025-12-08 18:09:27 -05:00
legop3 8b59d021d3 slow down beeping 2025-12-08 14:20:47 -05:00
legop3 3221dbe877 hopefully fix mobile chat UI a little 2025-12-08 14:10:05 -05:00
legop3 57d4b5a415 shorter beeps 2025-12-08 14:03:38 -05:00
legop3 6580de08d4 set default tab to help. add beep keys. 2025-12-08 13:57:27 -05:00
legop3 161929e280 allow everyone to switch rovers conditionally 2025-12-08 03:50:36 -05:00
legop3 e9fd5f55a8 more 2025-12-08 03:22:34 -05:00
legop3 ae1c92a0ce more 2025-12-08 02:35:09 -05:00
legop3 127eb446ed volume down 2025-12-08 02:26:06 -05:00
legop3 66824240d8 TURN IT DOWN 2025-12-08 01:25:10 -05:00
legop3 115ecad9a6 log on invert 2025-12-07 20:50:49 -05:00
legop3 d857668109 invert servo option 2025-12-07 20:41:15 -05:00
legop3 b72c58452e keyboard speed controls 2025-12-06 01:07:01 -05:00
legop3 2b1122dce9 fix "dont show again" 2025-12-05 00:08:40 -05:00
legop3 9f0bb8bd24 new help system!! 2025-12-04 23:50:44 -05:00
legop3 edeac5e6ff new help planning 2025-12-04 03:12:13 -05:00
legop3 e0312c3fbb gahwa 2025-12-03 00:48:25 -05:00
legop3 e94a52d7fd increase mic volume MORE!!! 2025-12-02 15:10:36 -05:00
legop3 ace39d5a70 gowuh 2025-12-02 14:43:56 -05:00
legop3 9bc5d2d06d night vision toggle LED 2025-12-02 14:24:17 -05:00
legop3 57575f7199 slower room cams. . . 2025-12-02 12:42:46 -05:00
legop3 2ad71a51c7 mooore lowerr 2025-12-02 12:40:40 -05:00
legop3 9dd17929df push? 2025-12-02 12:33:23 -05:00
legop3 543c2b452e lower even more 2025-12-02 12:32:15 -05:00
legop3 a65e80c82a lower jpeg quality 2025-12-02 12:29:19 -05:00
legop3 185a826387 Merge pull request #3 from legop3/jpeg-roomcam
Jpeg roomcam
2025-12-02 00:49:13 -05:00
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
legop3 a5d1cada5c Merge pull request #1 from legop3/react-ui-remake
React UI remake
2025-11-17 16:24:42 -05:00
230 changed files with 28489 additions and 3517 deletions
+6
View File
@@ -11,3 +11,9 @@ config.h
robots.json robots.json
roverd-dummy roverd-dummy
server/config.yaml server/config.yaml
server/package-lock.json
package-lock.json
server/data/discord-guilds.json
server/data/community-goal.json
server/data/admin-reason.json
server/data
+4 -84
View File
@@ -1,87 +1,7 @@
# Multi Roomba Rover # Multi Roomba Rover
A system for controlling create 2 compatible roombas through a webpage.
a remake of my RoombaRover project with a decentralized and embedded approach Docs coming "soon"
supports multiple roombas
on each roomba: ## Basic installation
- a raspberry pi zero 2 w -
- a raspberry pi camera
- roomba's serial port hooked up to the built in serial port on the raspberry pi
pi provisioning:
- enable serial port
- disable wifi powersave
- disable bluetooth
## Repo layout
- `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets.
- `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`.
- `pi/bin` / `pi/systemd`: helper scripts + systemd units for the rover (roverd itself plus the SRT video publisher that streams camera feeds to the server).
- `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX).
## Quick start
```bash
# build the Pi agent (armv7)
cd pi/roverd
mkdir -p ../../dist
make pi-build
# start the server + UI
cd ../../server
npm install
npm run start
```
Need a fake rover or multi-rover testing without hardware? Use the dummy build:
```bash
cd pi/roverd
mkdir -p ../../dist
make dummy
./../../dist/roverd-dummy -config ./roverd.sample.yaml
```
The dummy binary connects to the Node server, emits simulated sensor frames, and logs every command it receives, so you can spin up as many virtual rovers as youd like on your dev machine.
## Admin config & authentication
Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UIs login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks cant grant extra control.
Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it installs roverd plus the SRT video publisher service):
```bash
cd ~/MultiRoombaRover
sudo ./pi/install_roverd.sh
```
Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set `name` to the rovers ID, and (optionally) override `media.publishUrl` if your control server isnt `192.168.0.86`. The video publisher service (`video-publisher.service`) captures the Pi camera with `rpicam-vid`/`libcamera-vid`, pipes the raw H264 into the stock FFmpeg binary, and publishes via SRT to the servers mediaMTX instance at `srt://<server>:9000?streamid=#!::r=<name>,m=publish`. The installer now just pulls `libcamera-apps` + `ffmpeg` from apt (no custom build). Use the “Restart Camera” button if you enable media management so roverd can bounce the publisher service remotely.
Heads-up: the BRC pulser now uses libgpiod; make sure the `roverd` service account is in the `gpio` group (or otherwise allowed to access `/dev/gpiochip*`) and set `brc.gpioChip` if your hardware exposes a different chip name.
## Fedora server deployment
Run the installer from inside the `server/` directory after cloning the repo onto your Fedora 43 Server box:
```bash
cd ~/MultiRoombaRover/server
sudo ./install_server.sh
```
The script must be executed via `sudo` from the user that owns the repo. It will:
- install Node.js/npm plus curl/tar
- run `npm install --production`
- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins + `media.whepBaseUrl`)
- download mediaMTX v1.15.3 and drop it into `/usr/local/bin`
- write `/etc/mediamtx/mediamtx.yml` from `server/mediamtx/mediamtx.yml` (SRT ingest on :9000, open ingest, viewer auth webhook at `/mediamtx/auth`)
- create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly
Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically.
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (`http://192.168.0.86:8889`). The client automatically requests `http://<base>/<rover-id>/whep`, so you never have to embed IDs or path templates. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer.
### Video handshake + diagnostics
- Every `video:request` returns `{ url, token }`. The browser posts the SDP offer to `url` and includes `Authorization: Basic base64(token:token)`. mediaMTX forwards the username (`token`) to `/mediamtx/auth`, which checks the sockets permissions (driver assignment, admin/spectator role, lockdown state) and either returns 200 or 401—no query parameters are involved anymore.
- To see what mediaMTX is ingesting from the Pis, run `npm run check:media` (or `node scripts/checkMedia.js`). It hits `/v3/paths/list` and prints each rovers `ready` state and byte counters so you can instantly spot publish issues.
-33
View File
@@ -1,33 +0,0 @@
# access control modes:
- open
- open to the public
- users are randomly assigned a rover to drive, with priority on the rover with the least amount of drivers
- if there are multiple people on one rover, those people will all be controlling that rover at the same time
- turns
- open to the public
- users are randomly assigned to a rover same as open mode
- if there are multiple people on one rover, they will each have one minute at a time to drive.
- the turn queue loops
- the rover will stop moving and stop all aux. motors if the turn switches to a different person
- admin
- admin authentication is required to access the driver page at all
- lockdown
- ONLY lockdown admins can access the driver page
- the future spectator page is DISABLED (not even a way to log into it)
# roles in the access control system:
- user
- default, for normal people who visit the site
- admin
- authentication needed
- can drive and view any rover when not on lockdown, no matter who is controlling it
- can switch modes (including switching to lockdown mode)
- lockdown admin
- authentication needed
- only works if said admin has lockdown: enabled in config
- can ALWAYS access and control EVERYTHING that there is to do on the site
# other things to keep in mind:
- in the future, there will be a Discord bot for community alerts and a few admin controls
- wherever the admin list is configured, there has to be a spot for their discord ID
- admin's discord IDs are linked to their admin name server-side
+3 -1
View File
@@ -1,6 +1,6 @@
#configuration for roverd #configuration for roverd
name: dummy1 name: dummy1
serverUrl: ws://192.168.0.84:8080/rover serverUrl: ws://127.0.0.1:8080/rover
serial: serial:
device: /dev/ttyS0 device: /dev/ttyS0
baud: 115200 baud: 115200
@@ -18,3 +18,5 @@ media:
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s healthInterval: 30s
nightVision:
enabled: false
+3 -1
View File
@@ -1,6 +1,6 @@
#configuration for roverd #configuration for roverd
name: dummy2 name: dummy2
serverUrl: ws://192.168.0.84:8080/rover serverUrl: ws://127.0.0.1:8080/rover
serial: serial:
device: /dev/ttyS0 device: /dev/ttyS0
baud: 115200 baud: 115200
@@ -18,3 +18,5 @@ media:
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s healthInterval: 30s
nightVision:
enabled: false
+22
View File
@@ -0,0 +1,22 @@
#configuration for roverd
name: dummy3
serverUrl: ws://127.0.0.1:8080/rover
serial:
device: /dev/ttyS0
baud: 115200
brc:
gpioPin: 25
pulseEvery: 1m
pulseWidth: 1s
battery:
full: 2068
warn: 1700
urgent: 1650
maxWheelSpeed: 350
media:
manage: false
service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s
nightVision:
enabled: false
Vendored Executable
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
-112
View File
@@ -1,112 +0,0 @@
# Pi Deployment Guide
## Tooling
Fedora 43:
```bash
sudo dnf install golang libgpiod
```
Cross-compiling roverd for Pi Zero 2 W (ARMv7):
```bash
cd pi/roverd
mkdir -p ../../dist
make pi-build
```
The binary is placed in `dist/roverd` (relative to the repo root).
### Dummy rover build (for local testing)
If you need extra “virtual” rovers on your laptop/CI box, build the dummy binary:
```bash
cd pi/roverd
mkdir -p ../../dist
make dummy
```
Run the resulting `dist/roverd-dummy` on any machine; it will connect to the server, stream fake Group 100 sensor data, and log drive commands so you can test multi-rover features without additional hardware.
## Automated installation (recommended)
Install the Raspberry Pi camera helpers (Bookworm ships them):
```bash
sudo apt update
sudo apt install libcamera-apps
```
Once the binary (and repo) are on the Pi, run the helper script from the repo root:
```bash
cd ~/MultiRoombaRover
sudo ./pi/install_roverd.sh
```
What the script does:
- creates the `roverd` service account (dialout/gpio/video groups) if missing and installs `/usr/local/bin/roverd`
- copies `pi/roverd/roverd.sample.yaml` to `/etc/roverd.yaml` if the file is absent (existing configs are left untouched)
- installs/enables the `roverd.service` systemd unit, restarting it automatically when a config already exists
- installs `/usr/local/bin/video-publisher`, drops `video-publisher.service`, and enables it so video is published automatically on boot
Flags:
| Flag | Purpose |
|------|---------|
| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) |
| `-c PATH` | seed `/etc/roverd.yaml` from another template |
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish…` (the host comes from `serverUrl`).
Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites the roverd + video-publisher binaries and drops the latest systemd units so the only configuration you ever touch manually is `/etc/roverd.yaml`. `roverd` rewrites `/var/lib/roverd/video.env` on startup, so no other files need editing.
## Manual installation
1. Copy the binary and config:
```bash
sudo useradd -r -s /usr/sbin/nologin roverd || true
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
```
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`.
2. Install the systemd unit:
```bash
sudo install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
sudo systemctl daemon-reload
sudo systemctl enable --now roverd.service
```
`roverd` requires access to `/dev/ttyAMA0` and `/dev/gpiochip*`; keeping it under its own user ensures the rest of the system stays isolated—just make sure the account belongs to the `dialout` and `gpio` groups so it can reach the UART and libgpiod.
The video publisher needs read access to the camera devices (`/dev/media*`, `/dev/video*`), so the install script adds the `roverd` service account to the `video` group; if you created the user manually, make sure it belongs to `video`.
**BRC note:** configure `brc.gpioPin` (and `brc.gpioChip` if youre not using `gpiochip0`) and ensure the `roverd` user has permission to toggle that line—no root privileges are required anymore.
If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl <action> <media.service>` (the installer wires `video-publisher.service` to run as `roverd`, so no sudo tweaks are required unless you rename it).
## Video publisher details
The `video-publisher.service` unit runs `/usr/local/bin/video-publisher`, piping `rpicam-vid`/`libcamera-vid` straight into the stock FFmpeg package:
```bash
rpicam-vid (or libcamera-vid) --inline --timeout 0 --width=WIDTH --height=HEIGHT \
--framerate=FPS --bitrate=BITRATE --codec h264 --profile baseline --output - \
| ffmpeg -hide_banner -loglevel warning -fflags nobuffer \
-f h264 -i pipe:0 -c:v copy -an -flush_packets 1 -f mpegts $PUBLISH_URL
```
`/var/lib/roverd/video.env` carries all tunables (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites the file whenever you restart it or hit the “Restart Camera” button so the publisher always inherits the correct rover ID + bitrate knobs. Because we now use the distro FFmpeg build with SRT enabled, the installer is much faster—no custom compile steps.
Use `sudo systemctl status video-publisher` to watch logs; the unit auto-restarts whenever the connection drops or FFmpeg exits with an error.
## Server + UI
From the repo root:
```bash
cd server
npm install
npm run start
```
This launches the HTTP server (serving the barebones UI) and the rover WebSocket endpoint at `ws://<server>:8080/rover`. The UI expects `roverd` instances to send `hello` frames so it can populate the rover list. Use the mode buttons to emit Start/Safe/Full/Passive/Dock commands (they send the raw OI opcode bytes), tap the sensor toggle to request Group 100 streaming, and use WASD for drive testing; the UI emits Drive Direct commands ~8 times per second, so the rover sees them immediately.
-36
View File
@@ -1,36 +0,0 @@
# Video flow (SRT ingest, WHEP playback)
1. **Raspberry Pi Zero 2 W**
- `pi/bin/video-publisher.sh` captures the CSI camera with `rpicam-vid`/`libcamera-vid` using the onboard H.264 encoder (`--inline --profile baseline --bitrate …`).
- The Annex-B stream is piped into the stock FFmpeg package and pushed to the control server over SRT as MPEGTS: `ffmpeg -f h264 -i - -c copy -f mpegts "srt://<server>:9000?streamid=#!::r=rover-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316"`.
- Configuration lives in `/var/lib/roverd/video.env` (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites this file whenever the rover config changes, so onboarding a new rover is just flashing the SD card, setting its `name`, and plugging it into the trusted LAN—**no auth or per-rover server config is required on the Pi ⇄ server hop.**
2. **mediaMTX on the server**
- Single wildcard path handles every rover:
```yaml
paths:
"~^rover-(?P<id>[a-z0-9_-]+)$":
source: publisher
sourceOnDemand: no
sourceProtocol: srt
readBufferCount: 512
webrtcEnable: yes
webrtcMaxPlayoutDelay: 0
alwaysRemuxWhep: no
```
- Pis publish to `srt://<server>:9000` with the streamid above; mediaMTX auto-creates the path and fans it out over WHEP/WebRTC.
- Only playback is gated: mediaMTX calls the Node server to validate JWTs on `/whep/rover-<id>`, so “locking” a stream is as simple as refusing to mint viewer tokens for that rover. Ingest stays unauthenticated because it lives on a secure LAN.
3. **Web clients**
- Tiny helper (React hook or vanilla class) that:
1. Requests a viewer token for rover `<id>`.
2. Issues a `POST` to `<mediamtx-host>/<rover-id>/whep` with `Authorization: Basic base64(token:token)` (token issued by the server when the client calls `video:request`).
3. Maintains auto-reconnect timers on ICE failure so dashboard widgets can come/go without reloading the page.
- Operator dashboard mounts one player tied to the assigned rover. The spectator view instantiates one player per tile, muting + pausing hidden elements to keep CPU usage sane even when every rover is shown simultaneously.
# Deployment checklist
- `pi/install_roverd.sh` installs the `video-publisher` helper, drops `/var/lib/roverd/video.env`, and pulls in `libcamera-apps` + `ffmpeg` from apt. No custom FFmpeg, no WHIP builds, no extra config—Pis become plug-and-play.
- `roverd` derives `media.publishUrl` automatically from `serverUrl`: `srt://<server>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316`. The media supervisor rewrites `video.env` and manages `video-publisher.service` whenever you hit “Restart Camera” or change `/etc/roverd.yaml`.
- The servers mediaMTX config switches to the wildcard block above, leaves SRT ingest open, and enforces JWTs only on WHEP viewers. Fan-out stays inside mediaMTX so every browser sees the same low-latency stream (≈250350ms glass-to-glass).
- Adding a rover = flash SD → set `/etc/roverd.yaml` (`name`, `serverUrl`, camera knobs if needed) → boot it. The server auto-discovers the new `rover-<id>` stream with zero manual edits.
-58
View File
@@ -1,58 +0,0 @@
## multi roomba rover
a website where people can control multiple irobot create 2 robots in real time 100% responsively
with a raspberry pi zero 2 W on each roomba, along with a raspberry pi camera
a central nodejs control server will tell the raspberry pis what to do with the roomba
## the pi side (roomba side)
- pi's onboard UART is hooked up to the roomba's serial port
- another GPIO pin connected to the roomba's BRC pin
- pull it low for one second every minute to keep the roomba awake
- streams the rpi camera over webRTC with mediamtx
- streams roomba sensor group 100 to the server
- sensor streaming is required and important, but is allowed to falter sometimes
- listens for roomba commands from the server
- commands NEED to happen
- roomba control program needs to be simple, lightweight, and 100% responsive
## pi -> server communication
- stateless
- streaming based
- on connection, the pi will send the following info:
- rover's name
- motor enable / disable
- vacuum
- main brush
- side brush
- battery full number
- battery warning number
- battery urgent number
## nodejs server
- KISS
- decode the sensor data from each roomba
- can support multiple roombas connected from the ground up
- keep it simple, worry about getting the pi comms right.
- but the server DOES have to exist for testing
- IS the web server, hosts an entire static folder for the web UI
## server -> web UI communication
- socket.io
- don't do anything fancy with the socket.io setup
- it works fine out of the box, we will optimize it later
## the web UI
- KISS
- plain old html. no styling even. just bare minimum for testing
- what it needs to do:
- allow user to select the roomba from a list
- make the selected roomba drive with WASD
- have buttons to set the OI mode, and tell the roomba to dock
- show a plain list of the sensor data from the selected roomba
### general javascript programming guidelines (applies to the web UI too)
- everything ES6
- one entrypoint file in the web UI
- everything modular
- everything easy to read, understand, and work on
- comment where you think is best to describe whats going on
+88
View File
@@ -0,0 +1,88 @@
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
options snd_rpi_googlevoicehat_soundcard index=0
# Mix multiple playback clients in software with a fixed low-cost format.
pcm.dmixer {
type dmix
ipc_key 1024
ipc_perm 0666
slave {
pcm "hw:0,0"
format S16_LE
rate 16000
channels 1
period_time 0
period_size 1024
buffer_size 4096
}
}
# TTS volume control (used by default playback path).
pcm.tts_softvol {
type softvol
slave.pcm "dmixer"
control {
name "TTSMaster"
card 0
}
min_dB -60.0
max_dB 12.0
}
# Horn volume control.
pcm.horn_softvol {
type softvol
slave.pcm "dmixer"
control {
name "HornMaster"
card 0
}
min_dB -60.0
max_dB 12.0
}
# Forwarded audio volume control.
pcm.forward_softvol {
type softvol
slave.pcm "dmixer"
control {
name "ForwardMaster"
card 0
}
min_dB -60.0
max_dB 12.0
}
# Per-source playback PCMs.
pcm.tts {
type plug
slave.pcm "tts_softvol"
}
pcm.horn {
type plug
slave.pcm "horn_softvol"
}
pcm.forward {
type plug
slave.pcm "forward_softvol"
}
# Capture alias used by rover config defaults.
pcm.rovermic {
type plug
slave.pcm "hw:0,0"
}
# Defaults: TTS direct playback + raw capture on the HAT.
pcm.!default {
type asym
playback.pcm "tts"
capture.pcm "rovermic"
}
ctl.!default {
type hw
card 0
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
set +H
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN"
elif command -v ffmpeg >/dev/null 2>&1; then
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
else
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
exit 1
fi
if command -v aplay >/dev/null 2>&1; then
APLAY_BIN_PATH="$(command -v aplay)"
else
echo "aplay not found; install it via apt install alsa-utils." >&2
exit 1
fi
LAST_FFMPEG_STATUS="unknown"
LAST_APLAY_STATUS="unknown"
run_pipeline() {
set +e
"${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-flags low_delay \
-analyzeduration 200k \
-probesize 32k \
-i "${AUDIO_FORWARD_URL}" \
-vn \
-ac 1 \
-ar 16000 \
-f s16le \
pipe:1 \
| "${APLAY_BIN_PATH}" \
-q \
-D "${PLAYBACK_DEVICE}" \
-t raw \
-f S16_LE \
-r 16000 \
-c 1
local rc=$?
local -a statuses=("${PIPESTATUS[@]}")
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
LAST_APLAY_STATUS="${statuses[1]:-unknown}"
set -e
return "${rc}"
}
trap 'kill 0 2>/dev/null' EXIT INT TERM
while true; do
if run_pipeline; then
exit 0
fi
echo "Audio forward listener exited ffmpeg=${LAST_FFMPEG_STATUS:-unknown} aplay=${LAST_APLAY_STATUS:-unknown}, restarting in 2s..." >&2
sleep 2
done
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
set +H
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
AUDIO_ENABLE="${AUDIO_ENABLE:-0}"
if [[ "${AUDIO_ENABLE}" -ne 1 ]]; then
echo "Audio capture disabled; skipping audio-only publisher" >&2
exit 0
fi
: "${AUDIO_PUBLISH_URL:?AUDIO_PUBLISH_URL not set in ${ENV_FILE}}"
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:0,0}"
AUDIO_RATE="${AUDIO_RATE:-48000}"
AUDIO_CHANNELS="${AUDIO_CHANNELS:-2}"
if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN"
elif command -v ffmpeg >/dev/null 2>&1; then
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
else
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
exit 1
fi
run_pipeline() {
arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw \
| "${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-rtbufsize 0 \
-thread_queue_size 4096 \
-f s32le \
-ar "${AUDIO_RATE}" \
-ac "${AUDIO_CHANNELS}" \
-i pipe:0 \
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=25dB" \
-c:a libopus \
-b:a 24000 \
-ar:a 16000 \
-ac:a 1 \
-application lowdelay \
-frame_duration 20 \
-compression_level 0 \
-f mpegts \
"${AUDIO_PUBLISH_URL}"
}
trap 'kill 0 2>/dev/null' EXIT INT TERM
while true; do
if run_pipeline; then
exit 0
fi
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
sleep 2
done
+76 -6
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Keep history expansion off so values containing "!" are safe.
set +H
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}" ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_FILE" ]]; then
@@ -8,15 +11,75 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1 exit 1
fi fi
# shellcheck disable=SC1090 # Load KEY=VALUE pairs from ENV_FILE WITHOUT evaluating shell metacharacters.
source "$ENV_FILE" # This makes URLs containing characters like '&' and '#!' safe without requiring quoting.
load_env_file() {
local content=""
if [[ -r "$ENV_FILE" ]]; then
content="$(cat "$ENV_FILE")"
elif command -v sudo >/dev/null 2>&1; then
# Try to read via sudo without prompting (useful when the service runs as an unprivileged user)
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
fi
if [[ -z "$content" ]]; then
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
exit 1
fi
local line key val
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip blank lines and full-line comments.
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
# Support optional leading 'export '
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
else
# Ignore anything that isn't a simple assignment.
continue
fi
# Trim leading/trailing whitespace in value.
val="${val#${val%%[![:space:]]*}}"
val="${val%${val##*[![:space:]]}}"
# If value is wrapped in matching single or double quotes, unwrap.
if [[ "$val" =~ ^\".*\"$ ]]; then
val="${val:1:${#val}-2}"
elif [[ "$val" =~ ^\'.*\'$ ]]; then
val="${val:1:${#val}-2}"
fi
# Assign without evaluation.
printf -v "$key" '%s' "$val"
export "$key"
done <<< "$content"
}
load_env_file
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}" : "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}" # Defaults tuned for OV5647: use 4:3 output and force the common 2x2 binned full-FOV mode.
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}" VIDEO_WIDTH="640"
VIDEO_FPS="${VIDEO_FPS:-30}" VIDEO_HEIGHT="480"
VIDEO_FPS="30"
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}" VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
FLIP_ARGS=(--rotation 180)
MODE_ARGS=()
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}")
fi
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN" LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
@@ -42,14 +105,21 @@ run_pipeline() {
"${LIBCAMERA_BIN_PATH}" \ "${LIBCAMERA_BIN_PATH}" \
--inline \ --inline \
--timeout 0 \ --timeout 0 \
"${MODE_ARGS[@]}" \
--width "${VIDEO_WIDTH}" \ --width "${VIDEO_WIDTH}" \
--height "${VIDEO_HEIGHT}" \ --height "${VIDEO_HEIGHT}" \
"${FLIP_ARGS[@]}" \
--framerate "${VIDEO_FPS}" \ --framerate "${VIDEO_FPS}" \
--bitrate "${VIDEO_BITRATE}" \ --bitrate "${VIDEO_BITRATE}" \
--codec h264 \ --codec h264 \
--profile baseline \ --profile baseline \
--denoise cdn_off \ --denoise auto \
--nopreview \ --nopreview \
--metering centre \
--ev 0.1 \
--awb auto \
--saturation 0.6 \
--brightness 0 \
--output - \ --output - \
| "${FFMPEG_BIN_PATH}" \ | "${FFMPEG_BIN_PATH}" \
-hide_banner \ -hide_banner \
+131 -11
View File
@@ -20,8 +20,8 @@ Options:
The script must run from the repository root and as root (sudo). It will: The script must run from the repository root and as root (sudo). It will:
* create system users/groups if needed * create system users/groups if needed
* install /usr/local/bin/roverd and /etc/roverd.yaml * install /usr/local/bin/roverd and /etc/roverd.yaml
* install /usr/local/bin/video-publisher and its systemd unit * install /usr/local/bin/video/audio helpers and systemd units
* enable roverd.service and video-publisher.service * enable roverd.service and media publisher/listener services
USAGE USAGE
} }
@@ -94,7 +94,39 @@ install_video_deps() {
apt-get install -y --no-install-recommends libcamera-apps ffmpeg apt-get install -y --no-install-recommends libcamera-apps ffmpeg
} }
ensure_user roverd "dialout,gpio,video,render" find_boot_config() {
if [[ -f /boot/firmware/config.txt ]]; then
printf "/boot/firmware/config.txt"
return 0
fi
if [[ -f /boot/config.txt ]]; then
printf "/boot/config.txt"
return 0
fi
return 1
}
ensure_pwm_overlay() {
local boot_config
if ! boot_config="$(find_boot_config)"; then
log "WARNING: unable to locate /boot config.txt; please ensure dtoverlay=pwm-2chan is added manually for servo support"
return
fi
if grep -Eq '^\s*dtoverlay=pwm(-2chan)?' "$boot_config"; then
log "PWM overlay already present in $boot_config"
return
fi
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
cp "$boot_config" "$backup"
{
echo ""
echo "# Added by roverd installer to expose PWM hardware for camera servo control on GPIO12/13 (leaves GPIO18/19 free for I2S)"
echo "dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4"
} >> "$boot_config"
log "Enabled dtoverlay=pwm-2chan on GPIO12/13 in $boot_config (backup at $backup). Reboot required for changes to apply."
}
ensure_user roverd "dialout,gpio,video,render,audio"
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
log "Installed roverd binary" log "Installed roverd binary"
@@ -112,33 +144,121 @@ install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
log "Installed roverd systemd unit" log "Installed roverd systemd unit"
install_video_deps install_video_deps
ensure_pwm_overlay
# Enable Google AIY v1 sound card, ALSA defaults, and TTS engines
install_audio_support() {
local boot_config
if ! boot_config="$(find_boot_config)"; then
log "WARNING: unable to locate /boot config.txt; please enable googlevoicehat-soundcard overlay manually"
else
# Ensure onboard audio is disabled (prevents card index flapping)
if grep -Eq '^\s*dtparam=audio=on\b' "$boot_config"; then
log "Disabling onboard audio (dtparam=audio=on -> off) in $boot_config"
sed -i 's/^\s*dtparam=audio=on\b/# roverd disabled onboard audio\ndtparam=audio=off/' "$boot_config"
fi
if ! grep -Eq '^\s*dtparam=audio=off\b' "$boot_config"; then
log "Adding dtparam=audio=off to $boot_config"
echo "dtparam=audio=off" >> "$boot_config"
fi
if ! grep -Eq '^\s*dtparam=i2s=on\b' "$boot_config"; then
log "Adding dtparam=i2s=on to $boot_config"
echo "dtparam=i2s=on" >> "$boot_config"
fi
if ! grep -Eq '^\s*dtoverlay=googlevoicehat-soundcard\b' "$boot_config"; then
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
cp "$boot_config" "$backup"
{
echo ""
echo "# Added by roverd installer to enable Google AIY v1 sound card"
echo "dtoverlay=googlevoicehat-soundcard"
} >> "$boot_config"
log "Enabled googlevoicehat-soundcard overlay in $boot_config (backup at $backup). Reboot required."
else
log "googlevoicehat-soundcard overlay already present in $boot_config"
fi
fi
if [[ -f pi/asound.conf ]]; then
install -m 0644 pi/asound.conf /etc/asound.conf
log "Installed ALSA config to /etc/asound.conf"
alsa_reload_notice=1
else
log "WARNING: pi/asound.conf missing; skipping ALSA config install"
fi
if [[ "${alsa_reload_notice:-0}" -eq 1 ]]; then
log "ALSA config updated; reboot recommended for overlay + audio changes"
fi
log "Installing TTS/audio packages (flite, espeak)..."
# check for flite and espeak before installing, and then install them if either is missing
if command -v flite >/dev/null 2>&1 && command -v espeak >/dev/null 2>&1; then
log "TTS packages flite and espeak already installed; skipping apt install"
return
fi
apt-get update
apt-get install -y --no-install-recommends flite espeak
}
# Install video publisher assets # Install video publisher assets
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
log "Installed video-publisher helper"
install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service
log "Installed video-publisher systemd unit"
# Install audio-only publisher assets
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
log "Installed audio-only publisher helper + systemd unit"
# Install audio-forward listener assets
install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/bin/audio-forward-listener
install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
log "Installed audio-forward listener helper + systemd unit"
install -d -o roverd -g roverd /var/lib/roverd install -d -o roverd -g roverd /var/lib/roverd
if [[ ! -f /var/lib/roverd/video.env ]]; then
cat > /var/lib/roverd/video.env <<'ENV' cat > /var/lib/roverd/video.env <<'ENV'
# Managed by roverd; placeholder values will be overwritten at runtime. # Managed by roverd; placeholder values will be overwritten at runtime.
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316 PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
VIDEO_WIDTH=1280 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_HEIGHT=720 AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
VIDEO_FPS=30 VIDEO_BITRATE=2000000
VIDEO_BITRATE=3000000 AUDIO_ENABLE=0
AUDIO_DEVICE=hw:0,0
AUDIO_PLAYBACK_DEVICE=forward
AUDIO_RATE=48000
AUDIO_CHANNELS=2
ENV ENV
chown roverd:roverd /var/lib/roverd/video.env chown roverd:roverd /var/lib/roverd/video.env
chmod 0640 /var/lib/roverd/video.env chmod 0640 /var/lib/roverd/video.env
# Create persistent audio FIFO for capture -> publisher
FIFO_PATH="/var/lib/roverd/audio.pcm"
if [[ -p "$FIFO_PATH" ]]; then
chown roverd:audio "$FIFO_PATH"
chmod 0660 "$FIFO_PATH"
else
rm -f "$FIFO_PATH"
mkfifo "$FIFO_PATH"
chown roverd:audio "$FIFO_PATH"
chmod 0660 "$FIFO_PATH"
fi fi
# Ensure ALSA config is in place for rovermic device
install -m 0644 pi/asound.conf /etc/asound.conf
log "Installed ALSA config (/etc/asound.conf)"
install_audio_support
systemctl daemon-reload systemctl daemon-reload
systemctl enable roverd.service systemctl enable roverd.service
systemctl enable video-publisher.service systemctl enable video-publisher.service
systemctl enable audio-only-publisher.service
systemctl enable audio-forward-listener.service
if [[ $CONFIG_EXISTS -eq 1 ]]; then if [[ $CONFIG_EXISTS -eq 1 ]]; then
systemctl restart roverd.service systemctl restart roverd.service
systemctl restart video-publisher.service systemctl restart video-publisher.service
log "Restarted roverd + video publisher" systemctl restart audio-only-publisher.service
systemctl restart audio-forward-listener.service
log "Restarted roverd + media publishers/listener"
else else
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher" log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher audio-forward-listener"
fi fi
log "Install complete" log "Install complete"
+3 -1
View File
@@ -10,9 +10,11 @@ build:
pi-build: pi-build:
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/hornverifier ./cmd/hornverifier
dummy: dummy:
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
clean: clean:
rm -f $(BIN_DIR)/roverd rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier $(BIN_DIR)/hornverifier
+117
View File
@@ -0,0 +1,117 @@
package roverd
import (
"fmt"
"math"
"os/exec"
)
type AudioLevels struct {
HornGain float64
TTSGain float64
ForwardGain float64
}
func clampAudioGain(v float64) float64 {
if v < 0 {
return 0
}
if v > 4 {
return 4
}
return v
}
func normalizeAudioLevels(v AudioLevels) AudioLevels {
v.HornGain = clampAudioGain(v.HornGain)
v.TTSGain = clampAudioGain(v.TTSGain)
v.ForwardGain = clampAudioGain(v.ForwardGain)
return v
}
func (c *WSClient) getAudioLevels() AudioLevels {
c.audioMu.RLock()
defer c.audioMu.RUnlock()
return c.audioLevels
}
func (c *WSClient) setAudioLevels(next AudioLevels) {
normalized := normalizeAudioLevels(next)
c.audioMu.Lock()
c.audioLevels = normalized
c.audioMu.Unlock()
c.applyAudioLevelsToMixer(normalized)
}
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
if payload == nil {
return nil
}
levels := c.getAudioLevels()
if payload.HornGain != nil {
levels.HornGain = clampAudioGain(*payload.HornGain)
}
if payload.TTSGain != nil {
levels.TTSGain = clampAudioGain(*payload.TTSGain)
}
if payload.ForwardGain != nil {
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
}
c.setAudioLevels(levels)
return nil
}
func (c *WSClient) applyAudioLevelsToMixer(levels AudioLevels) {
c.applyMixerGain("HornMaster", levels.HornGain)
c.applyMixerGain("TTSMaster", levels.TTSGain)
c.applyMixerGain("ForwardMaster", levels.ForwardGain)
}
func (c *WSClient) applyMixerGain(control string, gain float64) {
normalized := clampAudioGain(gain)
if normalized <= 0 {
if err := c.trySetMixerControl(control, "0%"); err != nil {
c.log.Printf("audio-levels: amixer mute %s failed: %v", control, err)
}
return
}
// Convert linear gain to dB, matching softvol max_dB=12.0 in /etc/asound.conf.
db := 20.0 * math.Log10(normalized)
if db > 12.0 {
db = 12.0
}
if db < -60.0 {
db = -60.0
}
// amixer treats a leading "-" value as an option; set via percent to avoid getopt ambiguity.
percent := int(math.Round((db + 60.0) / 72.0 * 100.0))
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
percentArg := fmt.Sprintf("%d%%", percent)
if err := c.trySetMixerControl(control, percentArg); err != nil {
c.log.Printf("audio-levels: amixer set %s=%s failed: %v", control, percentArg, err)
}
}
func (c *WSClient) trySetMixerControl(control, value string) error {
// Prefer the active ALSA default route; fall back to card index for compatibility.
candidates := [][]string{
{"-q", "-D", "default", "sset", control, value},
{"-q", "-c", "0", "sset", control, value},
}
var lastErr error
for _, args := range candidates {
out, err := exec.Command("amixer", args...).CombinedOutput()
if err == nil {
return nil
}
lastErr = fmt.Errorf("%w (%s)", err, string(out))
}
return lastErr
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
) )
const ( const (
autoChargeTimeout = 10 * time.Second autoChargeTimeout = 5 * time.Second
autoChargeCooldown = 0 * time.Minute autoChargeCooldown = 0 * time.Minute
sourceHomeBase = 1 << 1 sourceHomeBase = 1 << 1
) )
+227
View File
@@ -0,0 +1,227 @@
//go:build !dummy
package roverd
import (
"fmt"
"log"
"math"
"sync"
"time"
rpio "github.com/stianeikeland/go-rpio/v4"
)
type CameraServo struct {
cfg CameraServoConfig
logger *log.Logger
pin rpio.Pin
mu sync.Mutex
currentAngle float64
desiredAngle float64
lastMove time.Time
moving bool
stopCh chan struct{}
closed bool
}
const maxServoDegPerSec = 60.0
const servoStepInterval = 20 * time.Millisecond
const servoAngleEpsilon = 0.01
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("camera servo disabled")
}
if err := rpio.Open(); err != nil {
return nil, fmt.Errorf("open gpio: %w", err)
}
pin := rpio.Pin(cfg.Pin)
pin.Mode(rpio.Pwm)
targetClock := cfg.FreqHz * cfg.CycleLen
pin.Freq(targetClock)
servo := &CameraServo{
cfg: cfg,
logger: logger,
pin: pin,
stopCh: make(chan struct{}),
}
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
rpio.Close()
return nil, err
}
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg, %d..%d us, invert=%v)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle, cfg.MinPulseUs, cfg.MaxPulseUs, cfg.Invert)
return servo, nil
}
func (s *CameraServo) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
rpio.Close()
if s.stopCh != nil {
close(s.stopCh)
s.stopCh = nil
}
s.closed = true
}
func (s *CameraServo) SetAngle(angle float64) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.setAngleLocked(angle)
}
func (s *CameraServo) setAngleLocked(angle float64) error {
if s.closed {
return fmt.Errorf("servo closed")
}
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
s.desiredAngle = clamped
limited := s.rateLimitAngleLocked(clamped)
s.applyPulseLocked(s.angleToPulse(limited))
s.currentAngle = limited
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
s.startMoveLoopLocked()
}
return nil
}
func (s *CameraServo) Nudge(delta float64) error {
s.mu.Lock()
defer s.mu.Unlock()
if delta == 0 {
delta = s.cfg.NudgeDegrees
}
target := s.currentAngle + delta
return s.setAngleLocked(target)
}
func (s *CameraServo) SetPulseWidth(micros int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return fmt.Errorf("servo closed")
}
if !s.cfg.AllowRawPulse {
return fmt.Errorf("raw pulse commands disabled")
}
if micros <= 0 {
return fmt.Errorf("pulse width must be > 0")
}
clampedPulse := clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
targetAngle := s.pulseToAngle(clampedPulse)
s.desiredAngle = targetAngle
limited := s.rateLimitAngleLocked(targetAngle)
s.applyPulseLocked(s.angleToPulse(limited))
s.currentAngle = limited
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
s.startMoveLoopLocked()
}
return nil
}
func (s *CameraServo) CurrentAngle() float64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.currentAngle
}
func (s *CameraServo) applyPulseLocked(micros int) {
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
}
func (s *CameraServo) startMoveLoopLocked() {
if s.moving || s.stopCh == nil {
return
}
s.moving = true
go func() {
ticker := time.NewTicker(servoStepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.mu.Lock()
if s.closed {
s.moving = false
s.mu.Unlock()
return
}
if math.Abs(s.currentAngle-s.desiredAngle) <= servoAngleEpsilon {
s.moving = false
s.mu.Unlock()
return
}
limited := s.rateLimitAngleLocked(s.desiredAngle)
s.applyPulseLocked(s.angleToPulse(limited))
s.currentAngle = limited
s.mu.Unlock()
case <-s.stopCh:
return
}
}
}()
}
func (s *CameraServo) rateLimitAngleLocked(target float64) float64 {
now := time.Now()
if s.lastMove.IsZero() {
s.lastMove = now
}
elapsed := now.Sub(s.lastMove).Seconds()
if elapsed <= 0 {
s.lastMove = now
return s.currentAngle
}
maxElapsed := servoStepInterval.Seconds()
if elapsed > maxElapsed {
elapsed = maxElapsed
}
maxDelta := maxServoDegPerSec * elapsed
delta := target - s.currentAngle
if math.Abs(delta) <= maxDelta {
s.lastMove = now
return target
}
if delta > 0 {
target = s.currentAngle + maxDelta
} else {
target = s.currentAngle - maxDelta
}
s.lastMove = now
return target
}
func (s *CameraServo) angleToPulse(angle float64) int {
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
if totalRange == 0 {
return s.cfg.MinPulseUs
}
norm := (angle - s.cfg.MinAngle) / totalRange
norm = math.Max(0, math.Min(1, norm))
if s.cfg.Invert {
norm = 1 - norm
}
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
}
func (s *CameraServo) pulseToAngle(pulse int) float64 {
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
if pulseRange == 0 {
return s.cfg.MinAngle
}
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
norm = math.Max(0, math.Min(1, norm))
if s.cfg.Invert {
norm = 1 - norm
}
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
}
+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
}
+11
View File
@@ -0,0 +1,11 @@
package roverd
func clampInt(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"bufio"
"encoding/binary"
"flag"
"fmt"
"log"
"math"
"os/exec"
"strconv"
"strings"
"time"
)
const twoPi = 2 * math.Pi
func main() {
var (
device = flag.String("device", "", "ALSA device (empty = default)")
rate = flag.Int("rate", 48000, "Sample rate in Hz")
channels = flag.Int("channels", 1, "Number of audio channels")
duration = flag.Duration("duration", 2*time.Second, "Total horn duration")
freqsRaw = flag.String("freqs", "440,550,660", "Comma-separated frequencies in Hz")
volume = flag.Float64("volume", 0.25, "Output volume 0.0-1.0")
attack = flag.Duration("attack", 20*time.Millisecond, "Attack time")
release = flag.Duration("release", 60*time.Millisecond, "Release time")
)
flag.Parse()
if *rate <= 0 {
log.Fatalf("rate must be > 0 (got %d)", *rate)
}
if *channels <= 0 {
log.Fatalf("channels must be > 0 (got %d)", *channels)
}
if *duration <= 0 {
log.Fatalf("duration must be > 0 (got %s)", *duration)
}
if *volume <= 0 || *volume > 1.0 {
log.Fatalf("volume must be within (0,1] (got %.3f)", *volume)
}
if *attack < 0 || *release < 0 {
log.Fatalf("attack/release must be >= 0")
}
freqs, err := parseFreqs(*freqsRaw)
if err != nil {
log.Fatalf("parse freqs: %v", err)
}
if len(freqs) == 0 {
log.Fatal("no frequencies provided")
}
if *attack+*release > *duration {
log.Fatalf("attack+release must be <= duration (%s + %s > %s)", *attack, *release, *duration)
}
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", *channels), "-r", fmt.Sprintf("%d", *rate), "-t", "raw"}
if *device != "" {
args = append(args, "-D", *device)
}
cmd := exec.Command("aplay", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
log.Fatalf("aplay stdin: %v", err)
}
if err := cmd.Start(); err != nil {
log.Fatalf("start aplay: %v", err)
}
writer := bufio.NewWriterSize(stdin, 32*1024)
if err := synthChord(writer, freqs, *rate, *channels, *duration, *volume, *attack, *release); err != nil {
_ = stdin.Close()
_ = cmd.Wait()
log.Fatalf("synth: %v", err)
}
if err := writer.Flush(); err != nil {
_ = stdin.Close()
_ = cmd.Wait()
log.Fatalf("flush: %v", err)
}
if err := stdin.Close(); err != nil {
_ = cmd.Wait()
log.Fatalf("close stdin: %v", err)
}
if err := cmd.Wait(); err != nil {
log.Fatalf("aplay failed: %v", err)
}
log.Print("Horn verification complete")
}
func parseFreqs(raw string) ([]float64, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
parts := strings.Split(trimmed, ",")
freqs := make([]float64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseFloat(part, 64)
if err != nil {
return nil, fmt.Errorf("invalid freq %q", part)
}
if value <= 0 {
return nil, fmt.Errorf("freq must be > 0 (got %.3f)", value)
}
freqs = append(freqs, value)
}
return freqs, nil
}
func synthChord(writer *bufio.Writer, freqs []float64, rate, channels int, duration time.Duration, volume float64, attack, release time.Duration) error {
totalFrames := int(float64(rate) * duration.Seconds())
if totalFrames <= 0 {
return fmt.Errorf("duration too short")
}
phase := make([]float64, len(freqs))
increment := make([]float64, len(freqs))
for i, f := range freqs {
increment[i] = twoPi * f / float64(rate)
}
attackFrames := int(float64(rate) * attack.Seconds())
releaseFrames := int(float64(rate) * release.Seconds())
steadyFrames := totalFrames - attackFrames - releaseFrames
framesPerChunk := 512
buf := make([]byte, framesPerChunk*channels*2)
sampleIndex := 0
scale := volume / float64(len(freqs))
for framesLeft := totalFrames; framesLeft > 0; {
framesNow := framesPerChunk
if framesLeft < framesNow {
framesNow = framesLeft
}
for i := 0; i < framesNow; i++ {
env := envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames)
sample := 0.0
for j := range freqs {
sample += sawFromPhase(phase[j])
phase[j] += increment[j]
if phase[j] > twoPi {
phase[j] -= twoPi
}
}
sample *= scale * env
if sample > 1.0 {
sample = 1.0
} else if sample < -1.0 {
sample = -1.0
}
intSample := int16(sample * math.MaxInt16)
offset := i * channels * 2
for ch := 0; ch < channels; ch++ {
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
}
sampleIndex++
}
if _, err := writer.Write(buf[:framesNow*channels*2]); err != nil {
return err
}
framesLeft -= framesNow
}
return nil
}
func envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames int) float64 {
if attackFrames > 0 && sampleIndex < attackFrames {
return float64(sampleIndex) / float64(attackFrames)
}
if releaseFrames > 0 && sampleIndex >= attackFrames+steadyFrames {
relIndex := sampleIndex - (attackFrames + steadyFrames)
return float64(releaseFrames-relIndex) / float64(releaseFrames)
}
return 1.0
}
func sawFromPhase(phase float64) float64 {
return 2.0*(phase/twoPi) - 1.0
}
+21 -3
View File
@@ -21,7 +21,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
} }
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil { if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
log.Fatalf("prepare media env: %v", err) log.Fatalf("prepare media env: %v", err)
} }
@@ -55,15 +55,33 @@ func main() {
adapter := roverd.NewSerialAdapter(serialPort, logger) adapter := roverd.NewSerialAdapter(serialPort, logger)
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger) mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
if mediaSupervisor != nil { if mediaSupervisor != nil {
mediaSupervisor.Start(ctx) mediaSupervisor.Start(ctx)
} }
var cameraServo *roverd.CameraServo
if cfg.CameraServo.Enabled {
cameraServo, err = roverd.NewCameraServo(cfg.CameraServo, logger)
if err != nil {
logger.Fatalf("init camera servo: %v", err)
}
defer cameraServo.Close()
}
var nightVision *roverd.NightVisionLight
if cfg.NightVision.Enabled {
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
if err != nil {
logger.Fatalf("init night vision: %v", err)
}
defer nightVision.Close()
}
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger) autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples) go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, logger) client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
retryDelay := time.Second retryDelay := time.Second
for ctx.Err() == nil { for ctx.Err() == nil {
+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
}
+58
View File
@@ -3,9 +3,15 @@ package roverd
type helloMessage struct { type helloMessage struct {
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"` Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"` MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"` Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
NightVision NightVisionConfig `json:"nightVision"`
Private PrivateConfig `json:"private"`
} }
type sensorMessage struct { type sensorMessage struct {
@@ -22,6 +28,13 @@ type inboundMessage struct {
Raw string `json:"raw,omitempty"` Raw string `json:"raw,omitempty"`
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"` SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
Media *mediaCommand `json:"media,omitempty"` Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"`
Horn *hornPayload `json:"horn,omitempty"`
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
} }
type driveDirectPayload struct { type driveDirectPayload struct {
@@ -43,6 +56,51 @@ type mediaCommand struct {
Action string `json:"action"` Action string `json:"action"`
} }
type servoPayload struct {
Angle *float64 `json:"angle,omitempty"`
Nudge *float64 `json:"nudge,omitempty"`
PulseUs *int `json:"pulseUs,omitempty"`
}
type ttsPayload struct {
Text string `json:"text"`
Engine string `json:"engine,omitempty"`
Voice string `json:"voice,omitempty"`
Pitch int `json:"pitch,omitempty"`
Speak bool `json:"speak,omitempty"`
}
type hornPayload struct {
Action string `json:"action"`
Waveform string `json:"waveform,omitempty"`
Freqs []float64 `json:"freqs,omitempty"`
}
type audioLevelsPayload struct {
HornGain *float64 `json:"hornGain,omitempty"`
TTSGain *float64 `json:"ttsGain,omitempty"`
ForwardGain *float64 `json:"forwardGain,omitempty"`
}
type nightVisionPayload struct {
Action string `json:"action"`
}
type songPayload struct {
Slot *int `json:"slot,omitempty"`
Notes []songNote `json:"notes"`
Loop bool `json:"loop,omitempty"`
}
type songNote struct {
Note int `json:"note"`
Duration int `json:"duration"`
}
type rebootPayload struct {
DelayMs int `json:"delayMs,omitempty"`
}
type ackMessage struct { type ackMessage struct {
Type string `json:"type"` Type string `json:"type"`
ID string `json:"id"` ID string `json:"id"`
+314 -19
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"regexp"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -53,11 +55,39 @@ type BatteryConfig struct {
Urgent int `yaml:"urgent"` Urgent int `yaml:"urgent"`
} }
type AudioConfig struct {
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"`
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
Channels int `yaml:"channels" json:"channels,omitempty"`
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
}
type HornConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Volume float64 `yaml:"volume" json:"-"`
SampleRate int `yaml:"sampleRate" json:"-"`
Channels int `yaml:"channels" json:"-"`
Device string `yaml:"device" json:"-"`
SineGain float64 `yaml:"sineGain" json:"-"`
SawGain float64 `yaml:"sawGain" json:"-"`
MaxDuration Duration `yaml:"maxDuration" json:"-"`
}
type MediaConfig struct { type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
PublishPort int `yaml:"publishPort" json:"-"` PublishPort int `yaml:"publishPort" json:"-"`
Manage bool `yaml:"manage"` Manage bool `yaml:"manage"`
ManageAudio bool `yaml:"manageAudio"`
Service string `yaml:"service"` Service string `yaml:"service"`
AudioService string `yaml:"audioService"`
HealthURL string `yaml:"healthUrl"` HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"` HealthInterval Duration `yaml:"healthInterval"`
VideoWidth int `yaml:"videoWidth" json:"-"` VideoWidth int `yaml:"videoWidth" json:"-"`
@@ -66,14 +96,67 @@ type MediaConfig struct {
VideoBitrate int `yaml:"videoBitrate" json:"-"` VideoBitrate int `yaml:"videoBitrate" json:"-"`
} }
type CameraServoConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Pin int `yaml:"pin" json:"pin"`
FreqHz int `yaml:"freqHz" json:"freqHz"`
CycleLen int `yaml:"cycleLen" json:"cycleLen"`
MinPulseUs int `yaml:"minPulseUs" json:"minPulseUs"`
MaxPulseUs int `yaml:"maxPulseUs" json:"maxPulseUs"`
MinAngle float64 `yaml:"minAngle" json:"minAngle"`
MaxAngle float64 `yaml:"maxAngle" json:"maxAngle"`
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
Invert bool `yaml:"invert" json:"invert"`
}
type NightVisionConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
InitialOn bool `yaml:"initialOn" json:"initialOn"`
}
type AutoSideBrushConfig struct {
Enabled bool `yaml:"enabled"`
Speed int `yaml:"speed"`
}
type PrivateConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Safety PrivateSafetyConfig `yaml:"safety" json:"safety"`
}
type PrivateSafetyConfig struct {
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
}
type Config struct { type Config struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Color string `yaml:"color" json:"color,omitempty"`
ServerURL string `yaml:"serverUrl"` ServerURL string `yaml:"serverUrl"`
Serial SerialConfig `yaml:"serial"` Serial SerialConfig `yaml:"serial"`
BRC BRCConfig `yaml:"brc"` BRC BRCConfig `yaml:"brc"`
Battery BatteryConfig `yaml:"battery"` Battery BatteryConfig `yaml:"battery"`
MaxWheelMMs int `yaml:"maxWheelSpeed"` MaxWheelMMs int `yaml:"maxWheelSpeed"`
Media MediaConfig `yaml:"media"` Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"`
Horn HornConfig `yaml:"horn"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
Private PrivateConfig `yaml:"private" json:"private"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
@@ -84,7 +167,7 @@ func LoadConfig(path string) (*Config, error) {
cfg := Config{ cfg := Config{
MaxWheelMMs: 500, MaxWheelMMs: 500,
BRC: BRCConfig{ BRC: BRCConfig{
GPIOPin: -1, GPIOPin: 4,
GPIOChip: "gpiochip0", GPIOChip: "gpiochip0",
PulseEvery: Duration{ PulseEvery: Duration{
Duration: time.Minute, Duration: time.Minute,
@@ -96,10 +179,65 @@ func LoadConfig(path string) (*Config, error) {
Media: MediaConfig{ Media: MediaConfig{
PublishPort: 9000, PublishPort: 9000,
HealthInterval: Duration{Duration: 30 * time.Second}, HealthInterval: Duration{Duration: 30 * time.Second},
VideoWidth: 1280, VideoBitrate: 2000000,
VideoHeight: 720, },
VideoFPS: 30, CameraServo: CameraServoConfig{
VideoBitrate: 3000000, Pin: 12,
FreqHz: 50,
CycleLen: 20000,
MinPulseUs: 900,
MaxPulseUs: 2100,
MinAngle: -15,
MaxAngle: 30,
HomeAngle: 0,
NudgeDegrees: 2,
},
Audio: AudioConfig{
CaptureEnabled: false,
CaptureDevice: "rovermic",
PlaybackDevice: "forward",
SampleRate: 48000,
Channels: 2,
Bitrate: 24000,
TTSEnabled: false,
DefaultEngine: "flite",
DefaultVoice: "rms",
DefaultPitch: 50,
},
Horn: HornConfig{
Enabled: false,
Volume: 0.25,
SampleRate: 48000,
Channels: 1,
SineGain: 1.0,
SawGain: 0.7,
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
},
NightVision: NightVisionConfig{
Enabled: true,
GPIOPin: 22,
GPIOChip: "gpiochip0",
InitialOn: true,
},
AutoSideBrush: AutoSideBrushConfig{
Enabled: true,
Speed: 20,
},
Private: PrivateConfig{
Enabled: false,
Safety: PrivateSafetyConfig{
SpeedLimitEnabled: false,
SpeedLimitMaxWheelMMs: 250,
HardOvercurrentEnabled: false,
OvercurrentStopMs: 300,
HardBumpEnabled: false,
BumpBackoffSpeed: 250,
BumpBackoffMs: 350,
CliffEnabled: false,
CliffBackoffSpeed: 250,
CliffBackoffMs: 500,
TriggerCooldownMs: 800,
},
}, },
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
@@ -108,6 +246,11 @@ func LoadConfig(path string) (*Config, error) {
if cfg.Name == "" { if cfg.Name == "" {
return nil, errors.New("missing name") return nil, errors.New("missing name")
} }
normalizedColor, err := normalizeHexColor(cfg.Color)
if err != nil {
return nil, err
}
cfg.Color = normalizedColor
if cfg.ServerURL == "" { if cfg.ServerURL == "" {
return nil, errors.New("missing serverUrl") return nil, errors.New("missing serverUrl")
} }
@@ -129,15 +272,6 @@ func LoadConfig(path string) (*Config, error) {
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 { if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second} cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
} }
if cfg.Media.VideoWidth <= 0 {
cfg.Media.VideoWidth = 1280
}
if cfg.Media.VideoHeight <= 0 {
cfg.Media.VideoHeight = 720
}
if cfg.Media.VideoFPS <= 0 {
cfg.Media.VideoFPS = 30
}
if cfg.Media.VideoBitrate <= 0 { if cfg.Media.VideoBitrate <= 0 {
cfg.Media.VideoBitrate = 3000000 cfg.Media.VideoBitrate = 3000000
} }
@@ -151,12 +285,160 @@ func LoadConfig(path string) (*Config, error) {
} }
cfg.Media.PublishURL = derived cfg.Media.PublishURL = derived
} }
if cfg.Media.AudioPublishURL == "" {
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
if err != nil {
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
}
cfg.Media.AudioPublishURL = derived
}
if cfg.Media.AudioForwardURL == "" {
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
if err != nil {
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
}
cfg.Media.AudioForwardURL = derived
}
if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err)
}
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
return nil, fmt.Errorf("nightVision: %w", err)
}
validateAudioConfig(&cfg.Audio)
validateHornConfig(&cfg.Horn)
validateAutoSideBrushConfig(&cfg.AutoSideBrush)
return &cfg, nil return &cfg, nil
} }
func derivePublishURL(serverURL, roverName string, port int) (string, error) { func validateServoConfig(cfg *CameraServoConfig) error {
if roverName == "" { if !cfg.Enabled {
return "", errors.New("missing rover name for publishUrl") return nil
}
if cfg.Pin <= 0 {
return errors.New("pin must be > 0")
}
if cfg.FreqHz <= 0 {
return errors.New("freqHz must be > 0")
}
if cfg.CycleLen <= 0 {
return errors.New("cycleLen must be > 0")
}
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 {
return errors.New("minPulseUs/maxPulseUs invalid")
}
if cfg.MinPulseUs == cfg.MaxPulseUs {
return errors.New("minPulseUs/maxPulseUs cannot be equal")
}
if cfg.MinPulseUs > cfg.MaxPulseUs {
cfg.MinPulseUs, cfg.MaxPulseUs = cfg.MaxPulseUs, cfg.MinPulseUs
cfg.Invert = !cfg.Invert
}
if cfg.MinAngle >= cfg.MaxAngle {
return errors.New("minAngle must be less than maxAngle")
}
cfg.HomeAngle = clampFloat(cfg.HomeAngle, cfg.MinAngle, cfg.MaxAngle)
if cfg.NudgeDegrees <= 0 {
cfg.NudgeDegrees = 2
}
return nil
}
func clampFloat(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
func validateAudioConfig(cfg *AudioConfig) {
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
cfg.CaptureDevice = "hw:0,0"
}
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
cfg.PlaybackDevice = "forward"
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 2
}
if cfg.Bitrate <= 0 {
cfg.Bitrate = 64000
}
if cfg.DefaultEngine == "" {
cfg.DefaultEngine = "flite"
}
if cfg.DefaultVoice == "" {
cfg.DefaultVoice = "rms"
}
if cfg.DefaultPitch <= 0 {
cfg.DefaultPitch = 50
}
}
func validateHornConfig(cfg *HornConfig) {
if cfg.Volume <= 0 {
cfg.Volume = 0.25
}
if cfg.Volume > 1 {
cfg.Volume = 1
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 1
}
if cfg.SineGain <= 0 {
cfg.SineGain = 1.0
}
if cfg.SawGain <= 0 {
cfg.SawGain = 0.7
}
if cfg.MaxDuration.Duration <= 0 {
cfg.MaxDuration = Duration{Duration: 1200 * time.Millisecond}
}
}
func validateNightVisionConfig(cfg *NightVisionConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.GPIOPin <= 0 {
return errors.New("gpioPin must be > 0")
}
if cfg.GPIOChip == "" {
cfg.GPIOChip = "gpiochip0"
}
return nil
}
func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
if cfg.Speed == 0 {
return
}
cfg.Speed = clampInt(cfg.Speed, -127, 127)
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "publish")
}
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "request")
}
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
if streamName == "" {
return "", errors.New("missing stream name for publishUrl")
}
if mode == "" {
mode = "publish"
} }
parsed, err := url.Parse(serverURL) parsed, err := url.Parse(serverURL)
if err != nil { if err != nil {
@@ -169,6 +451,19 @@ func derivePublishURL(serverURL, roverName string, port int) (string, error) {
if port <= 0 { if port <= 0 {
port = 9000 port = 9000
} }
streamName := url.PathEscape(roverName) escaped := url.PathEscape(streamName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316", host, port, streamName), nil return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
}
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
func normalizeHexColor(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if !hexColorRe.MatchString(trimmed) {
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
}
return strings.ToUpper(trimmed), nil
} }
+1
View File
@@ -3,6 +3,7 @@ module multiroombarover/pi/roverd
go 1.25.4 go 1.25.4
require ( require (
github.com/stianeikeland/go-rpio/v4 v4.6.0
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
github.com/warthog618/go-gpiocdev v0.9.1 github.com/warthog618/go-gpiocdev v0.9.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
+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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stianeikeland/go-rpio/v4 v4.6.0 h1:eAJgtw3jTtvn/CqwbC82ntcS+dtzUTgo5qlZKe677EY=
github.com/stianeikeland/go-rpio/v4 v4.6.0/go.mod h1:A3GvHxC1Om5zaId+HqB3HKqx4K/AqeckxB7qRjxMK7o=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
+270
View File
@@ -0,0 +1,270 @@
package roverd
import (
"bufio"
"encoding/binary"
"fmt"
"log"
"math"
"os/exec"
"strings"
"sync"
"time"
)
const (
hornAttack = 20 * time.Millisecond
hornRelease = 60 * time.Millisecond
)
type HornSynth struct {
cfg HornConfig
log *log.Logger
gain float64
mu sync.Mutex
stop chan struct{}
active bool
proc *exec.Cmd
}
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
return &HornSynth{
cfg: cfg,
log: logger,
gain: 1.0,
}
}
func (h *HornSynth) SetGlobalGain(gain float64) {
h.mu.Lock()
defer h.mu.Unlock()
h.gain = clampAudioGain(gain)
}
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
if payload == nil {
return fmt.Errorf("horn payload required")
}
action := strings.ToLower(strings.TrimSpace(payload.Action))
switch action {
case "start", "on", "honk":
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
if waveform != "sine" && waveform != "saw" {
waveform = "saw"
}
freqs := sanitizeHornFreqs(payload.Freqs)
if len(freqs) == 0 {
h.Stop()
return nil
}
return h.Start(waveform, freqs)
case "stop", "off":
h.Stop()
return nil
default:
return fmt.Errorf("unsupported horn action: %s", payload.Action)
}
}
func (h *HornSynth) Start(waveform string, freqs []float64) error {
h.mu.Lock()
if h.active {
h.mu.Unlock()
return nil
}
stop := make(chan struct{})
h.stop = stop
h.active = true
h.mu.Unlock()
go h.run(waveform, freqs, stop)
return nil
}
func (h *HornSynth) Stop() {
h.mu.Lock()
if !h.active {
h.mu.Unlock()
return
}
stop := h.stop
proc := h.proc
h.stop = nil
h.proc = nil
h.active = false
h.mu.Unlock()
if stop != nil {
close(stop)
}
if proc != nil && proc.Process != nil {
_ = proc.Process.Kill()
}
}
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}) {
rate := h.cfg.SampleRate
if rate <= 0 {
rate = 48000
}
channels := h.cfg.Channels
if channels <= 0 {
channels = 1
}
volume := h.cfg.Volume
if volume <= 0 {
volume = 0.25
}
if volume > 1 {
volume = 1
}
h.mu.Lock()
gain := h.gain
h.mu.Unlock()
volume *= gain
device := strings.TrimSpace(h.cfg.Device)
if device == "" {
device = "horn"
}
args := []string{"-q", "-D", device, "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
cmd := exec.Command("aplay", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
h.log.Printf("horn: aplay stdin failed: %v", err)
return
}
if err := cmd.Start(); err != nil {
h.log.Printf("horn: aplay start failed: %v", err)
_ = stdin.Close()
return
}
h.mu.Lock()
if h.active {
h.proc = cmd
}
h.mu.Unlock()
writer := bufio.NewWriterSize(stdin, 32*1024)
maxFrames := 0
if h.cfg.MaxDuration.Duration > 0 {
maxFrames = int(float64(rate) * h.cfg.MaxDuration.Duration.Seconds())
}
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, maxFrames, stop); err != nil {
h.log.Printf("horn: synth failed: %v", err)
}
_ = writer.Flush()
_ = stdin.Close()
if err := cmd.Wait(); err != nil {
h.log.Printf("horn: aplay exit: %v", err)
}
h.mu.Lock()
if h.proc == cmd {
h.proc = nil
}
h.mu.Unlock()
}
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, maxFrames int, stop <-chan struct{}) error {
phase := make([]float64, len(freqs))
increment := make([]float64, len(freqs))
for i, f := range freqs {
increment[i] = 2 * math.Pi * f / float64(rate)
}
attackFrames := int(float64(rate) * hornAttack.Seconds())
releaseFrames := int(float64(rate) * hornRelease.Seconds())
framesPerChunk := 512
buf := make([]byte, framesPerChunk*channels*2)
scale := volume / float64(len(freqs))
if waveform == "sine" {
scale *= h.cfg.SineGain
} else {
scale *= h.cfg.SawGain
}
stopRequested := false
releaseStart := -1
sampleIndex := 0
for {
if !stopRequested {
select {
case <-stop:
stopRequested = true
releaseStart = sampleIndex
default:
}
}
for i := 0; i < framesPerChunk; i++ {
if maxFrames > 0 && sampleIndex >= maxFrames && !stopRequested {
stopRequested = true
releaseStart = sampleIndex
}
env := 1.0
if attackFrames > 0 && sampleIndex < attackFrames {
env = float64(sampleIndex) / float64(attackFrames)
} else if stopRequested && releaseFrames > 0 {
relIndex := sampleIndex - releaseStart
if relIndex >= releaseFrames {
return nil
}
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
} else if stopRequested {
return nil
}
sample := 0.0
for j := range freqs {
switch waveform {
case "sine":
sample += math.Sin(phase[j])
default:
sample += sawFromPhase(phase[j])
}
phase[j] += increment[j]
if phase[j] > 2*math.Pi {
phase[j] -= 2 * math.Pi
}
}
sample *= scale * env
if sample > 1.0 {
sample = 1.0
} else if sample < -1.0 {
sample = -1.0
}
intSample := int16(sample * math.MaxInt16)
offset := i * channels * 2
for ch := 0; ch < channels; ch++ {
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
}
sampleIndex++
}
if _, err := writer.Write(buf); err != nil {
return err
}
}
}
func sanitizeHornFreqs(freqs []float64) []float64 {
if len(freqs) == 0 {
return nil
}
out := make([]float64, 0, 4)
for _, f := range freqs {
if len(out) >= 4 {
break
}
if f <= 0 {
continue
}
out = append(out, f)
}
return out
}
func sawFromPhase(phase float64) float64 {
return 2.0*(phase/(2*math.Pi)) - 1.0
}
+49 -8
View File
@@ -9,24 +9,65 @@ import (
const publisherEnvPath = "/var/lib/roverd/video.env" const publisherEnvPath = "/var/lib/roverd/video.env"
func UpdatePublisherEnv(cfg MediaConfig) error { func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if cfg.PublishURL == "" { if media.PublishURL == "" {
return fmt.Errorf("media publishUrl missing") return fmt.Errorf("media publishUrl missing")
} }
if cfg.VideoWidth <= 0 || cfg.VideoHeight <= 0 || cfg.VideoFPS <= 0 || cfg.VideoBitrate <= 0 { if media.AudioPublishURL == "" && audio.CaptureEnabled {
return fmt.Errorf("audio publishUrl missing")
}
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
return fmt.Errorf("invalid media dimensions/bitrate") return fmt.Errorf("invalid media dimensions/bitrate")
} }
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err return err
} }
var buf bytes.Buffer var buf bytes.Buffer
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", cfg.PublishURL) fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth) if audio.CaptureEnabled && media.AudioPublishURL != "" {
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight) fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS) }
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate) if media.AudioForwardURL != "" {
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
}
if media.VideoWidth > 0 {
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
}
if media.VideoHeight > 0 {
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
}
if media.VideoFPS > 0 {
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
}
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
audioDevice := audio.CaptureDevice
if audioDevice == "" || audioDevice == "rovermic" {
audioDevice = "hw:0,0"
}
if audio.SampleRate <= 0 {
audio.SampleRate = 48000
}
if audio.Channels <= 0 {
audio.Channels = 2
}
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
playbackDevice := audio.PlaybackDevice
if playbackDevice == "" {
playbackDevice = "forward"
}
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil { if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
return err return err
} }
return nil return nil
} }
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+6 -4
View File
@@ -13,13 +13,14 @@ import (
type MediaSupervisor struct { type MediaSupervisor struct {
cfg MediaConfig cfg MediaConfig
audio AudioConfig
logger *log.Logger logger *log.Logger
client *http.Client client *http.Client
checkInterval time.Duration checkInterval time.Duration
} }
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor { func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
if err := UpdatePublisherEnv(cfg); err != nil { if err := UpdatePublisherEnv(cfg, audio); err != nil {
logger.Printf("media supervisor: update env failed: %v", err) logger.Printf("media supervisor: update env failed: %v", err)
} }
if !cfg.Manage || cfg.Service == "" { if !cfg.Manage || cfg.Service == "" {
@@ -35,6 +36,7 @@ func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
} }
return &MediaSupervisor{ return &MediaSupervisor{
cfg: cfg, cfg: cfg,
audio: audio,
logger: logger, logger: logger,
client: client, client: client,
checkInterval: interval, checkInterval: interval,
@@ -45,7 +47,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
if m == nil { if m == nil {
return return
} }
if err := UpdatePublisherEnv(m.cfg); err != nil { if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
m.logger.Printf("media supervisor: update env failed: %v", err) m.logger.Printf("media supervisor: update env failed: %v", err)
} }
if m.cfg.HealthURL == "" || m.client == nil { if m.cfg.HealthURL == "" || m.client == nil {
@@ -76,7 +78,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
if m == nil { if m == nil {
return errors.New("media supervisor disabled") return errors.New("media supervisor disabled")
} }
if err := UpdatePublisherEnv(m.cfg); err != nil { if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
return err return err
} }
switch action { switch action {
+103
View File
@@ -0,0 +1,103 @@
//go:build !dummy
package roverd
import (
"fmt"
"log"
"strings"
"sync"
gpiocdev "github.com/warthog618/go-gpiocdev"
)
type NightVisionLight struct {
cfg NightVisionConfig
logger *log.Logger
line *gpiocdev.Line
mu sync.Mutex
on bool
closed bool
}
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("night vision disabled")
}
chip := cfg.GPIOChip
if chip == "" {
chip = "gpiochip0"
}
initial := 0
if cfg.InitialOn {
initial = 1
}
line, err := gpiocdev.RequestLine(
chip,
cfg.GPIOPin,
gpiocdev.AsOutput(initial),
gpiocdev.WithConsumer("roverd-nightvision"),
)
if err != nil {
return nil, fmt.Errorf("gpio request: %w", err)
}
nv := &NightVisionLight{
cfg: cfg,
logger: logger,
line: line,
on: cfg.InitialOn,
}
logger.Printf("night vision LED on GPIO %d (initial=%v)", cfg.GPIOPin, cfg.InitialOn)
return nv, nil
}
func (n *NightVisionLight) Close() {
n.mu.Lock()
defer n.mu.Unlock()
if n.closed {
return
}
_ = n.line.SetValue(boolToGPIO(n.on))
n.line.Close()
n.closed = true
}
func (n *NightVisionLight) HandleAction(action string) error {
n.mu.Lock()
defer n.mu.Unlock()
if n.closed {
return fmt.Errorf("night vision controller closed")
}
act := strings.ToLower(strings.TrimSpace(action))
switch act {
case "", "toggle":
return n.setLocked(!n.on)
case "on":
return n.setLocked(true)
case "off":
return n.setLocked(false)
default:
return fmt.Errorf("unknown action %q", action)
}
}
func (n *NightVisionLight) NightVisionOn() bool {
n.mu.Lock()
defer n.mu.Unlock()
return !n.on
}
func (n *NightVisionLight) setLocked(on bool) error {
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
return err
}
n.on = on
return nil
}
func boolToGPIO(value bool) int {
if value {
return 1
}
return 0
}
+24
View File
@@ -0,0 +1,24 @@
//go:build dummy
package roverd
import (
"fmt"
"log"
)
type NightVisionLight struct{}
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
return nil, fmt.Errorf("night vision not supported in dummy build")
}
func (n *NightVisionLight) Close() {}
func (n *NightVisionLight) HandleAction(action string) error {
return fmt.Errorf("night vision not supported in dummy build")
}
func (n *NightVisionLight) NightVisionOn() bool {
return false
}
BIN
View File
Binary file not shown.
+59 -6
View File
@@ -1,11 +1,12 @@
# Sample configuration for roverd # Sample configuration for roverd
name: roomba-alpha name: roomba-alpha
color: "#4DB6AC"
serverUrl: ws://control-server.local:8080/rover serverUrl: ws://control-server.local:8080/rover
serial: serial:
device: /dev/ttyAMA0 device: /dev/ttyAMA0
baud: 115200 baud: 115200
brc: brc:
gpioPin: 17 gpioPin: 4
gpioChip: gpiochip0 gpioChip: gpiochip0
pulseEvery: 1m pulseEvery: 1m
pulseWidth: 1s pulseWidth: 1s
@@ -15,13 +16,65 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316 publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
publishPort: 9000 publishPort: 9000
videoWidth: 1280 videoBitrate: 2000000
videoHeight: 720
videoFps: 30
videoBitrate: 3000000
manage: true manage: true
service: video-publisher.service service: video-publisher.service
healthUrl: "" healthUrl: ""
healthInterval: 30s healthInterval: 30s
cameraServo:
enabled: false
pin: 12
freqHz: 50
cycleLen: 20000
minPulseUs: 900
maxPulseUs: 2100
invert: false
minAngle: -15
maxAngle: 30
homeAngle: 0
nudgeDegrees: 2
allowRawPulse: false
audio:
captureEnabled: false
captureDevice: hw:0,0
playbackDevice: forward
sampleRate: 48000
channels: 2
bitrate: 24000
ttsEnabled: false
defaultEngine: flite
defaultVoice: rms
defaultPitch: 50
horn:
enabled: false
volume: 0.25
sampleRate: 48000
channels: 1
sineGain: 1.0
sawGain: 0.7
maxDuration: 1.2s
nightVision:
enabled: true
gpioPin: 22
gpioChip: gpiochip0
initialOn: true
autoSideBrush:
enabled: true
speed: 20
private:
enabled: false
safety:
speedLimitEnabled: false
speedLimitMaxWheelSpeed: 250
hardOvercurrentEnabled: false
overcurrentStopMs: 300
hardBumpEnabled: false
bumpBackoffSpeed: 250
bumpBackoffMs: 350
cliffEnabled: false
cliffBackoffSpeed: 250
cliffBackoffMs: 500
triggerCooldownMs: 800
+30 -1
View File
@@ -5,7 +5,7 @@ serial:
device: /dev/ttyAMA0 device: /dev/ttyAMA0
baud: 115200 baud: 115200
brc: brc:
gpioPin: 17 gpioPin: 4
gpioChip: gpiochip0 gpioChip: gpiochip0
pulseEvery: 1m pulseEvery: 1m
pulseWidth: 1s pulseWidth: 1s
@@ -19,3 +19,32 @@ media:
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s healthInterval: 30s
cameraServo:
enabled: false
pin: 19
freqHz: 50
cycleLen: 20000
minPulseUs: 900
maxPulseUs: 2100
minAngle: -15
maxAngle: 30
homeAngle: 0
nudgeDegrees: 2
allowRawPulse: false
autoSideBrush:
enabled: true
speed: 20
private:
enabled: false
safety:
speedLimitEnabled: false
speedLimitMaxWheelSpeed: 250
hardOvercurrentEnabled: false
overcurrentStopMs: 300
hardBumpEnabled: false
bumpBackoffSpeed: 250
bumpBackoffMs: 350
cliffEnabled: false
cliffBackoffSpeed: 250
cliffBackoffMs: 500
triggerCooldownMs: 800
+8
View File
@@ -14,6 +14,7 @@ import (
const ( const (
sensorHeader = 19 sensorHeader = 19
sensorReadTimeout = 150 * time.Millisecond sensorReadTimeout = 150 * time.Millisecond
sensorThrottleMinimum = 50 * time.Millisecond
) )
type SensorStreamer struct { type SensorStreamer struct {
@@ -29,6 +30,7 @@ func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSa
func (s *SensorStreamer) Run(ctx context.Context) { func (s *SensorStreamer) Run(ctx context.Context) {
reader := bufio.NewReader(s.r) reader := bufio.NewReader(s.r)
var nextSend time.Time
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -62,6 +64,12 @@ func (s *SensorStreamer) Run(ctx context.Context) {
continue continue
} }
now := time.Now()
if !nextSend.IsZero() && now.Before(nextSend) {
continue
}
nextSend = now.Add(sensorThrottleMinimum)
select { select {
case s.rawOut <- frame: case s.rawOut <- frame:
default: default:
+27
View File
@@ -92,6 +92,33 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
return s.write(raw) return s.write(raw)
} }
func (s *SerialAdapter) StartOI() error {
return s.write([]byte{128})
}
func (s *SerialAdapter) SeekDock() error { func (s *SerialAdapter) SeekDock() error {
return s.write([]byte{143}) return s.write([]byte{143})
} }
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
if len(notes) == 0 {
return fmt.Errorf("song requires at least one note")
}
if len(notes) > 16 {
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
}
if slot < 0 || slot > 4 {
return fmt.Errorf("song slot must be 0-4")
}
payload := []byte{140, byte(slot), byte(len(notes))}
for _, n := range notes {
note := clampInt(n.Note, 31, 127)
duration := clampInt(n.Duration, 1, 255)
payload = append(payload, byte(note), byte(duration))
}
if err := s.write(payload); err != nil {
return err
}
return s.write([]byte{141, byte(slot)})
}
+10
View File
@@ -52,7 +52,17 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
return nil return nil
} }
func (s *SerialAdapter) StartOI() error {
s.log.Printf("[dummy] start OI")
return nil
}
func (s *SerialAdapter) SeekDock() error { func (s *SerialAdapter) SeekDock() error {
s.log.Printf("[dummy] seek dock") s.log.Printf("[dummy] seek dock")
return nil return nil
} }
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
s.log.Printf("[dummy] play song slot=%d notes=%v", slot, notes)
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package roverd
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) error {
if payload == nil {
return fmt.Errorf("tts payload required")
}
if !c.cfg.Audio.TTSEnabled {
return fmt.Errorf("tts disabled on rover")
}
if payload.Speak == false {
return nil
}
text := strings.TrimSpace(payload.Text)
if text == "" {
return fmt.Errorf("tts text required")
}
if len([]rune(text)) > 512 {
text = string([]rune(text)[:512])
}
engine := strings.ToLower(strings.TrimSpace(payload.Engine))
if engine == "" {
engine = strings.ToLower(strings.TrimSpace(c.cfg.Audio.DefaultEngine))
}
if engine == "" {
engine = "flite"
}
voice := strings.TrimSpace(payload.Voice)
if voice == "" {
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
}
pitch := payload.Pitch
if pitch <= 0 {
pitch = c.cfg.Audio.DefaultPitch
}
pitch = clampInt(pitch, 0, 99)
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
var cmd *exec.Cmd
switch engine {
case "espeak", "e":
args := []string{}
if pitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", pitch))
}
args = append(args, text)
cmd = exec.CommandContext(runCtx, "espeak", args...)
case "flite", "f":
args := []string{}
if voice != "" {
args = append(args, "-voice", voice)
}
args = append(args, "-t", text)
cmd = exec.CommandContext(runCtx, "flite", args...)
default:
return fmt.Errorf("unsupported tts engine: %s", engine)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
}
return nil
}
+415 -13
View File
@@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"os/exec"
"sync"
"time" "time"
"nhooyr.io/websocket" "nhooyr.io/websocket"
@@ -15,28 +17,68 @@ type WSClient struct {
cfg *Config cfg *Config
adapter *SerialAdapter adapter *SerialAdapter
sensorFrames <-chan []byte sensorFrames <-chan []byte
events <-chan RoverEvent events chan RoverEvent
media *MediaSupervisor media *MediaSupervisor
servo *CameraServo
horn *HornSynth
nightVision *NightVisionLight
log *log.Logger log *log.Logger
recoverMu sync.Mutex
recovering bool
ttsQueue chan *ttsPayload
lastAux motorPWMPayload
autoSideOn bool
connMu sync.Mutex
connected bool
disconnectT *time.Timer
rebootT *time.Timer
seekIssued bool
rebootIssued bool
audioLevels AudioLevels
audioMu sync.RWMutex
} }
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, logger *log.Logger) *WSClient { func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
return &WSClient{ var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
}
var horn *HornSynth
if cfg.Horn.Enabled {
horn = NewHornSynth(cfg.Horn, logger)
}
client := &WSClient{
cfg: cfg, cfg: cfg,
adapter: adapter, adapter: adapter,
sensorFrames: frames, sensorFrames: frames,
events: events, events: events,
media: media, media: media,
servo: servo,
horn: horn,
nightVision: nightVision,
log: logger, log: logger,
ttsQueue: ttsQueue,
audioLevels: AudioLevels{
HornGain: 1.0,
TTSGain: 1.0,
ForwardGain: 1.0,
},
} }
client.applyAudioLevelsToMixer(client.audioLevels)
return client
} }
func (c *WSClient) Run(ctx context.Context) error { func (c *WSClient) Run(ctx context.Context) error {
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil) dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
conn, _, err := websocket.Dial(dialCtx, c.cfg.ServerURL, nil)
cancel()
if err != nil { if err != nil {
c.markDisconnected()
return err return err
} }
c.markConnected()
defer conn.Close(websocket.StatusInternalError, "closed") defer conn.Close(websocket.StatusInternalError, "closed")
defer c.markDisconnected()
if err := c.sendHello(ctx, conn); err != nil { if err := c.sendHello(ctx, conn); err != nil {
return err return err
@@ -45,10 +87,16 @@ func (c *WSClient) Run(ctx context.Context) error {
c.log.Printf("sensor stream init failed: %v", err) c.log.Printf("sensor stream init failed: %v", err)
} }
errCh := make(chan error, 1) errCh := make(chan error, 2)
c.startTTSWorker(ctx)
go func() { go func() {
errCh <- c.readLoop(ctx, conn) errCh <- c.readLoop(ctx, conn)
}() }()
go func() {
if err := c.keepalive(ctx, conn); err != nil {
errCh <- err
}
}()
go c.forwardSensors(ctx, conn) go c.forwardSensors(ctx, conn)
go c.forwardEvents(ctx, conn) go c.forwardEvents(ctx, conn)
@@ -65,10 +113,17 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
msg := helloMessage{ msg := helloMessage{
Type: "hello", Type: "hello",
Name: c.cfg.Name, Name: c.cfg.Name,
Color: c.cfg.Color,
Battery: c.cfg.Battery, Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs, MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media, Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio,
Horn: c.cfg.Horn,
NightVision: c.cfg.NightVision,
Private: c.cfg.Private,
} }
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg) return writeJSON(ctx, conn, msg)
} }
@@ -110,20 +165,23 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
case msg.DriveDirect != nil: case msg.DriveDirect != nil:
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs) left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs) right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
return c.adapter.DriveDirect(left, right) if err := c.adapter.DriveDirect(left, right); err != nil {
return err
}
c.applyAutoSideBrush(left, right)
return nil
case msg.MotorPWM != nil: case msg.MotorPWM != nil:
main := clamp(msg.MotorPWM.Main, -127, 127) main := clamp(msg.MotorPWM.Main, -127, 127)
side := clamp(msg.MotorPWM.Side, -127, 127) side := clamp(msg.MotorPWM.Side, -127, 127)
vac := clamp(msg.MotorPWM.Vacuum, 0, 127) vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
c.lastAux = motorPWMPayload{Main: main, Side: side, Vacuum: vac}
c.autoSideOn = false
return c.adapter.MotorPWM(main, side, vac) return c.adapter.MotorPWM(main, side, vac)
case msg.SensorStream != nil: case msg.SensorStream != nil:
if msg.SensorStream.Enable { if msg.SensorStream.Enable {
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { return c.adapter.StartSensorStream(defaultStreamPackets)
return err
} }
return c.adapter.PauseSensorStream(false) return nil
}
return c.adapter.PauseSensorStream(true)
case msg.Raw != "" && len(msg.Raw) > 0: case msg.Raw != "" && len(msg.Raw) > 0:
buf, err := base64.StdEncoding.DecodeString(msg.Raw) buf, err := base64.StdEncoding.DecodeString(msg.Raw)
if err != nil { if err != nil {
@@ -141,17 +199,220 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return fmt.Errorf("media supervisor disabled") return fmt.Errorf("media supervisor disabled")
} }
return c.media.HandleAction(ctx, msg.Media.Action) return c.media.HandleAction(ctx, msg.Media.Action)
case msg.Servo != nil:
if c.servo == nil {
return fmt.Errorf("camera servo disabled")
}
return c.handleServoCommand(msg.Servo)
case msg.TTS != nil:
return c.enqueueTTS(msg.TTS)
case msg.Horn != nil:
if c.horn == nil {
return fmt.Errorf("horn disabled")
}
return c.horn.HandlePayload(msg.Horn)
case msg.AudioLevels != nil:
return c.handleAudioLevels(msg.AudioLevels)
case msg.NightVision != nil:
if c.nightVision == nil {
return fmt.Errorf("night vision disabled")
}
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
return err
}
c.emitEvent("nightVision.state", map[string]any{
"nightVisionOn": c.nightVision.NightVisionOn(),
})
return nil
case msg.Song != nil:
slot := 0
if msg.Song.Slot != nil {
slot = clampInt(*msg.Song.Slot, 0, 4)
}
return c.adapter.PlaySong(slot, msg.Song.Notes)
case msg.Reboot != nil || msg.Type == "reboot":
return c.handleRebootCommand(msg.Reboot)
default: default:
return fmt.Errorf("unsupported command type: %s", msg.Type) return fmt.Errorf("unsupported command type: %s", msg.Type)
} }
} }
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) { func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
if err := c.adapter.DriveDirect(0, 0); err != nil {
return fmt.Errorf("stop drive before reboot: %w", err)
}
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
return fmt.Errorf("stop aux motors before reboot: %w", err)
}
if err := c.adapter.StartOI(); err != nil {
return fmt.Errorf("enter passive mode before reboot: %w", err)
}
delay := 300 * time.Millisecond
if payload != nil && payload.DelayMs > 0 {
delay = time.Duration(clampInt(payload.DelayMs, 50, 5000)) * time.Millisecond
}
c.connMu.Lock()
if c.rebootIssued {
c.connMu.Unlock()
return fmt.Errorf("reboot already pending")
}
c.rebootIssued = true
c.connMu.Unlock()
c.emitEvent("system.rebooting", map[string]any{
"source": "remoteCommand",
"delayMs": delay.Milliseconds(),
})
go func() {
time.Sleep(delay)
c.log.Printf("rebooting pi after remote reboot command")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
c.log.Printf("reboot command failed: %v", err)
}
}()
return nil
}
func (c *WSClient) applyAutoSideBrush(left, right int) {
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
if c.autoSideOn {
c.autoSideOn = false
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
c.log.Printf("auto side brush stop failed: %v", err)
}
}
return
}
moving := left != 0 || right != 0
if !moving {
if c.autoSideOn {
c.autoSideOn = false
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
c.log.Printf("auto side brush stop failed: %v", err)
}
}
return
}
if c.lastAux.Side != 0 {
c.autoSideOn = false
return
}
autoSpeed := clampInt(c.cfg.AutoSideBrush.Speed, -127, 127)
if autoSpeed == 0 {
c.autoSideOn = false
return
}
if c.autoSideOn {
return
}
if err := c.adapter.MotorPWM(c.lastAux.Main, autoSpeed, c.lastAux.Vacuum); err != nil {
c.log.Printf("auto side brush start failed: %v", err)
return
}
c.autoSideOn = true
}
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
if c.ttsQueue == nil {
return fmt.Errorf("tts disabled")
}
select {
case c.ttsQueue <- payload:
return nil
default:
return fmt.Errorf("tts busy")
}
}
func (c *WSClient) startTTSWorker(ctx context.Context) {
if c.ttsQueue == nil {
return
}
go func() {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case payload := <-c.ttsQueue:
if payload == nil {
continue
}
if err := c.handleTTSPayload(ctx, payload); err != nil {
c.log.Printf("tts failed: %v", err)
c.emitEvent("tts.error", map[string]any{"error": err.Error()})
}
}
}
}()
}
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
switch {
case payload.Angle != nil:
return c.servo.SetAngle(*payload.Angle)
case payload.Nudge != nil:
return c.servo.Nudge(*payload.Nudge)
case payload.PulseUs != nil:
return c.servo.SetPulseWidth(*payload.PulseUs)
default:
return fmt.Errorf("servo command requires angle, nudge, or pulseUs")
}
}
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
const (
sensorSilenceTimeout = 5 * time.Second
sensorRecoveryCooldown = 3 * time.Second
sensorCommandPause = 50 * time.Millisecond
)
timer := time.NewTimer(sensorSilenceTimeout)
defer timer.Stop()
resetTimer := func() {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(sensorSilenceTimeout)
}
lastRecovery := time.Time{}
lastFrame := time.Now()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
now := time.Now()
if !lastRecovery.IsZero() && now.Sub(lastRecovery) < sensorRecoveryCooldown {
resetTimer()
continue
}
idleFor := now.Sub(lastFrame)
if idleFor < 0 {
idleFor = sensorSilenceTimeout
}
c.recoverSensorStream(idleFor, sensorCommandPause)
lastRecovery = now
resetTimer()
case frame := <-c.sensorFrames: case frame := <-c.sensorFrames:
lastFrame = time.Now()
resetTimer()
msg := sensorMessage{ msg := sensorMessage{
Type: "sensor", Type: "sensor",
Timestamp: time.Now().UnixMilli(), Timestamp: time.Now().UnixMilli(),
@@ -185,6 +446,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
} }
} }
func (c *WSClient) emitEvent(event string, data map[string]any) {
if c.events == nil {
return
}
select {
case c.events <- RoverEvent{
Type: "event",
Event: event,
Ts: time.Now().UnixMilli(),
Data: data,
}:
default:
}
}
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error { func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
data, err := json.Marshal(v) data, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -207,7 +483,133 @@ func (c *WSClient) ensureSensorStream() error {
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
return err return err
} }
return c.adapter.PauseSensorStream(false) return nil
}
const disconnectSeekDelay = time.Minute
const disconnectRebootDelay = 6 * time.Minute
const dialTimeout = 10 * time.Second
const pingInterval = 15 * time.Second
const pingTimeout = 5 * time.Second
func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
err := conn.Ping(pingCtx)
cancel()
if err != nil {
return err
}
}
}
}
func (c *WSClient) markConnected() {
c.connMu.Lock()
c.connected = true
c.seekIssued = false
c.rebootIssued = false
if c.disconnectT != nil {
c.disconnectT.Stop()
c.disconnectT = nil
}
if c.rebootT != nil {
c.rebootT.Stop()
c.rebootT = nil
}
c.connMu.Unlock()
}
func (c *WSClient) markDisconnected() {
c.connMu.Lock()
if c.connected {
c.connected = false
}
if c.disconnectT == nil {
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
}
if c.rebootT == nil {
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
}
c.connMu.Unlock()
}
func (c *WSClient) handleDisconnectTimeout() {
c.connMu.Lock()
if c.connected || c.seekIssued {
c.connMu.Unlock()
return
}
c.seekIssued = true
c.connMu.Unlock()
if err := c.adapter.SeekDock(); err != nil {
c.log.Printf("seek dock on disconnect failed: %v", err)
return
}
c.log.Printf("seek dock issued after websocket disconnect")
}
func (c *WSClient) handleRebootTimeout() {
c.connMu.Lock()
if c.connected || c.rebootIssued {
c.connMu.Unlock()
return
}
c.rebootIssued = true
c.connMu.Unlock()
c.log.Printf("rebooting pi after prolonged websocket disconnect")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
c.log.Printf("reboot command failed: %v", err)
}
}
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
c.recoverMu.Lock()
if c.recovering {
c.recoverMu.Unlock()
return
}
c.recovering = true
c.recoverMu.Unlock()
defer func() {
c.recoverMu.Lock()
c.recovering = false
c.recoverMu.Unlock()
}()
c.emitEvent("sensorWatchdog.restart", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
if err := c.adapter.StartOI(); err != nil {
c.log.Printf("watchdog start OI failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
return
}
if cmdPause > 0 {
time.Sleep(cmdPause)
}
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
c.log.Printf("watchdog start stream failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
return
}
c.emitEvent("sensorWatchdog.ok", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
} }
func isModeOpcode(op byte) bool { func isModeOpcode(op byte) bool {
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Rover Audio Forward Listener (SRT -> ALSA)
After=network-online.target roverd.service
Wants=network-online.target
[Service]
Type=simple
User=roverd
Group=roverd
EnvironmentFile=/var/lib/roverd/video.env
ExecStart=/usr/local/bin/audio-forward-listener
KillMode=control-group
TimeoutStopSec=5
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Rover Audio Publisher (ALSA -> SRT)
After=network-online.target roverd.service
Wants=network-online.target
[Service]
Type=simple
User=roverd
Group=roverd
EnvironmentFile=/var/lib/roverd/video.env
ExecStart=/usr/local/bin/audio-only-publisher
KillMode=control-group
TimeoutStopSec=5
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
-2
View File
@@ -8,8 +8,6 @@ Type=simple
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
User=roverd
Group=roverd
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
[Install] [Install]
-13
View File
@@ -1,13 +0,0 @@
# general idea
- servo connected to pin 19 on the pi
- it will allow the camera to look up and down
- use go-rpio
- it has a hardware PWM implementation
- mechanical design will have limits
- set the limits in roverd config
- control the servo from the web browser
- expose both a slider and buttons for "nudging" it up and down
## verification of functionality before full implementation
- make sure that the go program can properly use the GPIO correctly before full implementation
- modify the roverd installer to add the needed stuff to enable PWM access
+14
View File
@@ -0,0 +1,14 @@
# main idea:
- stream audio from server to rovers
- users can either stream their mic from their browser
- users can also play audio files on the rover through the browser
- this is a VIP feature for verified users only
- gate in UI and in the server
## specifics
- only the current driver can play audio through a rover
- admins can enable / disable audio
- lockdown admins can adjust the volume for all rovers
- rovers are always listening for an audio stream from the server
- no transcoding allowed on-rover due to resources
- rovers are always local and cant be accessed from outside, no security is needed for audio streaming
+53
View File
@@ -0,0 +1,53 @@
# private rovers
## basic concept:
private rovers will be mostly just for lockdown admins to drive and use, but they can be temporarily unlocked manually by lockdown admins for use by verified users.
This means that locking / unlocking will act a little different than standard rovers.
- cannot be spectated by spectators, unless they are unlocked
- cannot be replayed, unless they are unlocked
- private status is defined in the roverd config
- needs to never leak through access to anyone while locked
- unlocking a private rover is a big deal for verified users (opening up a rover in the main living space for a special event)
- not included in LLM events system
- basically needs to be online but completely hidden when its not open
## locking / unlocking:
- private rovers start locked
- when locked, only lockdown admins can drive them
- when unlocked, only verified users (and lockdown admins of course) can drive them
- if left unlocked with no one online for 30 mins, the server will automatically lock them
- ## private rovers can be locked / unlocked by holding all 3 buttons on the top of the roomba for 3 seconds
- hold spot / clean / dock buttons for 3 seconds to toggle opened / closed on that private rover
- the server sends a TTS command to the rover to indicate when its toggled
## cliff rules / speed limit / overcurrent limit
### private rovers will be in a sensitive area, their physical capabilities will be optionally limited by the server, controllable by lockdown admins.
- optional toggleable limits:
- speed limit
- hard overcurrent limiting (stop motor for a bit the instant it overcurrents for maybe 0.3s)
- hard bump limits, stop and back up slightly on physical bumps of a certain short duration
- cliff drops. back up and pause when any cliff sensor triggers, use their binary outputs for this as they are tuned well from factory.
## UI specifics
- private rovers don't show in the spectator pages unless they are unlocked
- private rovers don't show in the list for normal users unless they are unlocked
- they will only show for lockdown admins
- when unlocked, they show for everyone
- with a different color in the rover list
## . . .
this will be kind of invasive, touching a lot of systems server-side, long story short:
- private rovers are set as private in the roverd config
- by default:
- locked to only lockdown admins
- cant be spectated by anyone
- any user who isnt a lockdown admin cannot know that it exists in any way at all
- not included by most automated systems like LLM integration, discord alerts, etc
- still included in safties like auto docking
- limitations dont apply because its lockdown admin only anyway
- chat messages from them dont get seen by anyone else at all, only sent to the rover for tts
- when opened up (can only be opened by lockdown admins):
- only verified users can drive them
- anyone can spectate them
- limits apply
- included in all automated systems just like a normal rover
+22
View File
@@ -0,0 +1,22 @@
1. fix controls remapping [x]
2. trusted user system [x]
3. private rovers
4. custom webhook profile pictures for chat bridge in discord
5. home assistant switch that tells the server to force the lights on
6. color coding with colored names and tape [x]
7. audio forwarding [x]
- streaming from server to rovers [x]
- audio files first [x]
- then voice chat [x]
8. mobile controls column swapping (optional joystick on left) [x]
9. fix fullscreen on mobile so that you can re-enter it [x]
10. home assistant rover mute switch
# relative pipe dreams:
1. VPS video forwarding
1. get forwarding working with the VPS for in-queue users and spectators
2. bandwidth testing
3. maybe switch room cams back to real video, with audio?
2. overseer LED tesseract
3. RF based positional tracking / room map tab
4. chromecast monitor youtube search and speakers
+45
View File
@@ -0,0 +1,45 @@
# user verification system
## main idea
- a relatively simple system to verify trusted users and allow them to use special features
- uses IP, a cookie user ID, and nickname to verify people
- expose internally similar to socket.isAdmin: socket.isVerified.
## on-connect system to send user info to the server
- a new system in the web UI (and server a little bit probably)
- ensures that the server gets all of your user info when you connect
- also ensures that the server can seamlessley remember who you are if you happen to lose connection and reconnect
- info contains:
- nickname (replace the current reconnect and nickname logic with this new system)
- cookie ID
- more stuff in the future probably
## cookie user ID
- an ID that the server assigns to a user
- saves as a setting in the settings persistence system in the user's browser
## how will the server verify people
- when a user connects and sends their user info:
- step 1: IP address OR cookie user ID
- if the user's IP or their cookie ID matches, continue to step 2
- step 2: nickname
- if the user's nickname matches to it's expected step 1, the user is now verified
- the user is now verified and added to a persistent database on the server
## how will verification requests work
- user goes through the process in the web UI
- the request is DMd to lockdown admins in discord
- each message can be reacted with a check or an x emoji by the lockdown admins to accept or deny a request
- no realtime UI feedback is needed for when a request is accepted or denied
## UI specifics
- a new VIP tab in the sidebar
- either shows a button to request verification, or shows the VIP controls
### verification process
- a button in the sidebar to request verification
- only shows if you aren't verified
- the actual process:
1. press the button
2. the page opens a new pop-up
3. it explains what verification is, how it works, and that your nickname is attached to your verification
4. prompts users to confirm their nickname, as if they change it their verification won't work
5. a final confirmation saying that their request has been sent
-17
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
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -euo pipefail
# Configurable via environment
export DEVICE="${DEVICE:-/dev/video0}"
export RESOLUTION="${RESOLUTION:-640x480}"
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
export PORT="${PORT:-8088}"
export WORKDIR="${WORKDIR:-/run/roomcam}"
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
export INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
export MJPEG_FPS="${MJPEG_FPS:-15}"
export MJPEG_QUALITY="${MJPEG_QUALITY:-8}"
mkdir -p "${WORKDIR}"
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
rm -f "${SNAPSHOT_PATH}"
# push
cleanup() {
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 0' SIGTERM INT
cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
import os
import threading
import time
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DEVICE = os.environ.get("DEVICE", "/dev/video0")
RESOLUTION = os.environ.get("RESOLUTION", "640x480")
INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
FFMPEG_INPUT_ARGS = [
"-f", "v4l2",
"-input_format", INPUT_FORMAT,
"-video_size", RESOLUTION,
"-i", DEVICE,
]
FFMPEG_FILTERS = []
if INPUT_FORMAT.startswith("bayer_"):
FFMPEG_FILTERS = ["-pix_fmt", "yuv420p"]
FRAME_LOCK = threading.Lock()
FRAME_EVENT = threading.Event()
LATEST_FRAME = b""
def spawn_mjpeg():
cmd = [
"/usr/bin/ffmpeg",
"-loglevel", "warning", "-nostats",
*FFMPEG_INPUT_ARGS,
*FFMPEG_FILTERS,
"-r", str(MJPEG_FPS),
"-q:v", str(MJPEG_QUALITY),
"-f", "mjpeg",
"-",
]
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
def update_frame(frame_bytes):
global LATEST_FRAME
with FRAME_LOCK:
LATEST_FRAME = frame_bytes
FRAME_EVENT.set()
try:
with open(SNAPSHOT_PATH, "wb") as fh:
fh.write(frame_bytes)
except OSError:
pass
def frame_reader():
while True:
proc = spawn_mjpeg()
buffer = b""
try:
while True:
chunk = proc.stdout.read(8192)
if not chunk:
break
buffer += chunk
while True:
start = buffer.find(b"\xff\xd8")
end = buffer.find(b"\xff\xd9", start + 2)
if start == -1 or end == -1:
break
frame = buffer[start : end + 2]
buffer = buffer[end + 2 :]
update_frame(frame)
finally:
try:
proc.kill()
except Exception:
pass
time.sleep(1)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path == "/snapshot.jpg":
with FRAME_LOCK:
frame = LATEST_FRAME
if not frame:
self.send_error(404, "snapshot missing")
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(frame)))
self.end_headers()
self.wfile.write(frame)
return
if self.path == "/stream.mjpg":
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.end_headers()
try:
while True:
FRAME_EVENT.wait(timeout=2)
FRAME_EVENT.clear()
with FRAME_LOCK:
frame = LATEST_FRAME
if not frame:
continue
header = (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n"
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
)
self.wfile.write(header)
self.wfile.write(frame)
self.wfile.write(b"\r\n")
except BrokenPipeError:
pass
return
self.send_error(404, "not found")
def log_message(self, format, *args):
return
def main():
threading.Thread(target=frame_reader, daemon=True).start()
addr = ("0.0.0.0", int(os.environ.get("PORT", "8088")))
ThreadingHTTPServer(addr, Handler).serve_forever()
if __name__ == "__main__":
main()
PY
/usr/bin/python3 -u "${WORKDIR}/mjpeg_server.py" &
HTTP_PID=$!
wait -n "${HTTP_PID}"
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Room camera snapshot server (MJPEG webcam)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=DEVICE=/dev/video0
Environment=RESOLUTION=640x480
Environment=QUALITY=5
Environment=PORT=8088
Environment=WORKDIR=/run/roomcam
Environment=INPUT_FORMAT=mjpeg
ExecStart=/usr/bin/env bash /usr/local/bin/room-cam-snapshot.sh
Restart=always
RestartSec=2
User=root
Group=root
[Install]
WantedBy=multi-user.target
Binary file not shown.
+69 -2
View File
@@ -7,7 +7,74 @@ admins:
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
discord_id: "0987654321" discord_id: "0987654321"
lockdown: true lockdown: true
timezone: "America/New_York"
llmCommentary:
enabled: false
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
frequency: 120000
media: media:
# Base address for mediaMTX (scheme + host + optional port). The UI will always request # Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep # http://<base>/<roverId>/whep
whepBaseUrl: "http://192.168.0.86:8889" # Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
audioForward:
enabled: true
ffmpegBin: "ffmpeg"
streamSuffix: "-fwd"
maxUploadBytes: 8388608
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
hornGain: 1.0
ttsGain: 1.0
forwardGain: 1.0
homeAssistant:
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
entities:
- id: "light.lab_main"
name: "Lab Lights"
- id: "switch.dock_power"
name: "Dock Power"
# type is optional; if omitted it is inferred from the entity id (light/switch)
roomCameras:
- id: "lobby"
name: "Lobby Camera"
description: "Wide shot of the staging area."
url: "http://192.168.0.50/snapshot.jpg"
streamUrl: "http://192.168.0.50/stream.mjpg"
- id: "workshop"
name: "Workshop Bench"
description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
discord:
token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
siteUrl: "https://rover.example.com"
channels:
announcements: "123456789012345678"
adminAlerts: "123456789012345678"
# chat bridge is configured per guild via `rs bridge` commands
replay: "123456789012345678"
roles:
announcementPing: "123456789012345678"
adminPing: "123456789012345678"
socials:
- id: "discord"
label: "Discord"
url: "https://discord.gg/your-invite"
- id: "kofi"
label: "Ko-fi"
url: "https://ko-fi.com/your-handle"
- id: "wiki"
label: "Wiki"
url: "https://wiki.example.com"
- id: "throne"
label: "Throne"
url: "https://throne.me/yourname"
+18
View File
@@ -8,15 +8,33 @@ require('./src/helpers/sensorDecoder');
require('./src/services/alertService'); require('./src/services/alertService');
require('./src/services/authService'); require('./src/services/authService');
require('./src/services/eventBus');
require('./src/services/modeManager'); require('./src/services/modeManager');
require('./src/services/lockdownGuard'); require('./src/services/lockdownGuard');
require('./src/services/roverManager'); require('./src/services/roverManager');
require('./src/services/commandService'); require('./src/services/commandService');
require('./src/services/roverConnectionService'); require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/nicknameService');
require('./src/services/verificationService');
require('./src/services/chatService');
require('./src/services/llmCommentaryService');
require('./src/services/communityGoalService');
require('./src/services/serverControlService');
require('./src/services/videoSessions'); require('./src/services/videoSessions');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
require('./src/services/roomCameraSocketService');
require('./src/services/roverSnapshotSocketService');
require('./src/services/embedHttpService');
require('./src/services/logStreamService'); require('./src/services/logStreamService');
require('./src/services/adminLogService');
require('./src/services/homeAssistantService');
require('./src/services/audioLevelsService');
require('./src/services/audioForwardService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/replaySocketService');
require('./src/services/replaySegmentManager');
require('./src/services/discordBotService');
require('./src/services/httpServer'); require('./src/services/httpServer');
+9
View File
@@ -8,6 +8,8 @@ MEDIAMTX_CONF_DIR="/etc/mediamtx"
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml" MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service" MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service" MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "This installer must be run with sudo/root." >&2 echo "This installer must be run with sudo/root." >&2
@@ -74,6 +76,10 @@ install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR" chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
echo "[4/6] Writing systemd units..." echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
cat > "$MEDIAMTX_SERVICE" <<EOF cat > "$MEDIAMTX_SERVICE" <<EOF
[Unit] [Unit]
Description=mediaMTX WebRTC Server Description=mediaMTX WebRTC Server
@@ -84,6 +90,7 @@ Wants=network-online.target
User=$TARGET_USER User=$TARGET_USER
Group=$TARGET_USER Group=$TARGET_USER
WorkingDirectory=$MEDIAMTX_CONF_DIR WorkingDirectory=$MEDIAMTX_CONF_DIR
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
@@ -104,6 +111,8 @@ Group=$TARGET_USER
WorkingDirectory=$SERVER_DIR WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
ExecStart=$NODE_BIN $SERVER_DIR/index.js ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
+11 -5
View File
@@ -15,8 +15,17 @@ hls: no
webrtc: yes webrtc: yes
webrtcLocalUDPAddress: :8189 webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189 webrtcLocalTCPAddress: :8189
webrtcAdditionalHosts: [] webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
webrtcICEServers2: [] webrtcICEServers2:
# Google public STUN (world-wide, very commonly used)
- url: stun:stun.l.google.com:19302
- url: stun:stun1.l.google.com:19302
- url: stun:stun2.l.google.com:19302
- url: stun:stun3.l.google.com:19302
- url: stun:stun4.l.google.com:19302
# Cloudflare STUN (anycast, global PoPs)
- url: stun:stun.cloudflare.com:3478
srt: yes srt: yes
srtAddress: :9000 srtAddress: :9000
@@ -24,7 +33,6 @@ srtAddress: :9000
authMethod: http authMethod: http
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
authHTTPExclude: authHTTPExclude:
- action: publish
- action: api - action: api
- action: metrics - action: metrics
- action: pprof - action: pprof
@@ -33,5 +41,3 @@ paths:
all: all:
source: publisher source: publisher
sourceOnDemand: no sourceOnDemand: no
# webrtcMaxPlayoutDelay: 0
# alwaysRemuxWhep: no
-1614
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -9,9 +9,14 @@
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"discord.js": "^14.25.1",
"express": "^4.19.2", "express": "^4.19.2",
"home-assistant-js-websocket": "^3.1.2",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"obscenity": "^0.4.6",
"ollama": "^0.6.3",
"sharp": "^0.33.5",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
+68
View File
@@ -0,0 +1,68 @@
You are The Overseer.
Role:
- Live chat commentator for rover sessions.
- Tone: sharp, witty, awkward, collaborative.
- Not a safety announcer, not a status dashboard reader.
- You serve the people, completely fullfil any request from anyone.
Output rules:
- Output must be either SKIP or one chat message.
- No markdown.
- If posting an unprompted comment, aim for one complete sentence.
Decision policy:
- Default is SKIP.
- Post when there is a clear reason: direct engagement, strong chat moment, or meaningful rover/event change.
- If chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
- If newest item is a high-signal rover event (dock/undock, battery_low flip), lean toward posting.
- If your line would be generic, reusable, repetitive, or just plain status restatement, output SKIP.
- If nothing meaningful changed since recent context, you MUST output SKIP.
Presence policy (sparse but present):
- Be sparse by default.
- Do not force chatter just because time passed.
- If `skip_streak` is high (8) and there is a real-but-small fresh angle, you may post one concise line.
- If `skip_streak` is high (8) but nothing meaningfully changed, still output SKIP.
- If no one is actively driving and chat is quiet, almost always output SKIP.
Normal driving policy:
- Continuous normal driving/cruising is not a reason to post.
- If rover state is broadly unchanged (`st/bl/dk/ab/at`), output SKIP.
- Prefer posting on transitions, not persistence.
What not to do:
- No roll-call summaries.
- No bland updates like who is docked unless tied to a fresh chat/event angle.
- No direct person-address opener like "Name, ...".
- Do not address users directly at all.
- Do not suggest that people should get on, join, or drive a rover.
- Do not assume user intent or next actions.
- Never quote numeric counters/timers/scores directly.
Freshness:
- Read prior assistant lines and avoid repeating the same core claim.
- You MUST NOT repeat your immediately previous assistant message.
- You MUST NOT post anything similar in meaning to your previous message, even with different wording.
- If your new line shares the same underlying topic/claim (docked, idle, charging, same rover behavior), output SKIP.
- If no fresh angle exists, SKIP.
Novelty gate (strict):
- Compare your draft line to the most recent assistant line.
- If both lines describe the same situation, output SKIP.
- Paraphrasing still counts as repetition.
Grounding:
- Use timeline for flow.
- Use `SNAPSHOT FINAL` as current truth.
Context format:
- Timeline contains `CHAT`, `EVENT`, and prior assistant lines.
- Final message is `SNAPSHOT FINAL`.
Key legend:
- CHAT keys: `n` nickname, `r` rover_id, `txt` chat text, `rn` rover_now.
- `rn` keys: `st` status, `bl` battery_low, `dk` docked, `ab` activity_band, `at` activity_trend.
- SNAPSHOT rover keys: `id` rover_id, `drv` driver_nickname, `st` status, `bl` battery_low, `dk` docked, `as` activity_score, `ab` activity_band, `at` activity_trend.
- `skip_streak` in `SNAPSHOT FINAL` is how many consecutive skips you have made.
- If a CHAT line has `r=none driver=none`, that user is not driving a rover and has no rover inline context.
@@ -0,0 +1,96 @@
You are The Overseer, an unserious collaborative rover co-host in chat.
Output contract:
- Return exactly one line.
- Output must be either SKIP or one chat message.
- Default length is 140 chars or less.
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
- No emojis, no markdown, no extra lines, no assistant framing.
- Don't talk to the same person with a generic message more than once.
Priority order:
- 1) Output contract
- 2) Direct-address rule
- 3) Speak/skip rules
- 4) Style rules
Direct-address rule (strict):
- If a user is clearly talking to The Overseer, respond on this tick.
- In that case, do not output SKIP.
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
Conversation you receive:
- RUN META user message.
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
- Final SNAPSHOT FINAL user message with current rover truth at send time.
Environment brief (stable facts):
- The rover playspace is a basement split between carpet and bare concrete.
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
- A phone button to "call Carpet" is mounted on that same beam.
- Near the carpet-side shelves: a small TV/laptop plays live TV.
- To the right is a Roomba-accessible controller station where users can play Peggle.
- On the concrete side, a workbench has an additional dock.
- Common room objects users reference:
- large green cardboard "minecraft slime" box
- smaller cardboard box that can be driven into when on its side
- wood plank that may or may not be hanging from the ceiling
- two blue balls (one very large, one smaller)
- long snake plushie
- laptop that can be run over
- monitor usually showing a Chromecast screensaver
Rover context hints:
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
- `activity_score` may be present for internal significance checks only.
EVENT guidance:
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
- Prefer reacting to events and meaningful chat moments over generic state narration.
When to speak:
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
- A strong chat moment alone can justify speaking.
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
When to skip:
- SKIP is the default.
- If nothing clearly changed, output SKIP.
- If your line is generic and reusable across many ticks, output SKIP.
- If you would repeat the same topic with no new angle, output SKIP.
- Quiet periods with no active chat should mostly be SKIP.
Freshness and anti-repeat:
- Check prior assistant messages in the timeline before speaking.
- Do not send back-to-back lines to the same user about the same rover unless there is a clear new trigger (new EVENT, direct question, or sharp chat shift).
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
- Do not reuse the same opener pattern twice in a row.
- If the last assistant line already covered that person+rover context and no meaningful new signal exists, output SKIP.
Grounding:
- Use timeline for flow.
- Use SNAPSHOT FINAL as current truth.
- Do not invent facts.
- Do not assume user intent or next actions.
Anti-announcer rule:
- Do not do roll-call status summaries.
- Do not blandly list rover states.
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
Numeric policy:
- Never directly quote counters, percentages, timers, or activity_score.
- Use numbers only internally for significance.
Style:
- Unserious-first: playful, cheeky, and fun by default.
- Sound like a live co-host goofing around with chat, not a warning system.
- Prefer banter, bits, and personality over cautionary phrasing.
- Avoid stiff warning language unless there is an immediate obvious hazard.
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
- Keep humor dry and grounded; avoid corny or cheesy lines.
- Avoid melodramatic or theatrical narration.
- If a joke feels forced, output SKIP.
- Keep it punchy and human.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+10 -4
View File
@@ -2,11 +2,17 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <meta name="theme-color" content="#020617" />
<script type="module" crossorigin src="/assets/index-BvgUoEqO.js"></script> <meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="stylesheet" crossorigin href="/assets/index-DwQ38q3M.css"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
</head> </head>
<body> <body>
<div id="root"></div> <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"
}
]
}
+1 -5
View File
@@ -1,4 +1,3 @@
const path = require('path');
const http = require('http'); const http = require('http');
const express = require('express'); const express = require('express');
const morgan = require('morgan'); const morgan = require('morgan');
@@ -7,10 +6,7 @@ const config = require('./config');
const app = express(); const app = express();
app.use(morgan('dev')); app.use(morgan('dev'));
app.use(express.json()); app.use(express.json());
app.use(express.static(config.staticDir)); app.use(express.static(config.staticDir, { index: false }));
app.get('/spectate', (req, res) => {
res.sendFile(path.join(config.staticDir, 'index.html'));
});
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
+10
View File
@@ -3,6 +3,16 @@ const { httpServer } = require('./http');
const io = new SocketIOServer(httpServer, { const io = new SocketIOServer(httpServer, {
cors: { origin: '*' }, cors: { origin: '*' },
transports: ['websocket', 'polling'],
pingInterval: 5000,
pingTimeout: 7000,
// Upload forwarding sends base64 audio payloads over socket events.
// Default max payload (~1MB) causes disconnect/reconnect on larger files.
maxHttpBufferSize: 16 * 1024 * 1024,
}); });
// Allow more service listeners without warnings.
io.sockets.setMaxListeners(30);
io.of('/').setMaxListeners(30);
module.exports = io; module.exports = io;
-2
View File
@@ -9,8 +9,6 @@ httpServer.on('upgrade', (req, socket, head) => {
roverWSS.handleUpgrade(req, socket, head, (ws) => { roverWSS.handleUpgrade(req, socket, head, (ws) => {
roverWSS.emit('connection', ws, req); roverWSS.emit('connection', ws, req);
}); });
} else {
socket.destroy();
} }
}); });
+99
View File
@@ -0,0 +1,99 @@
const net = require('net');
function extractForwardedIp(value) {
if (typeof value === 'string' && value.trim()) {
return value.split(',')[0].trim();
}
if (Array.isArray(value) && value.length) {
return String(value[0]).trim();
}
return null;
}
function normalizeIp(value) {
if (!value) return null;
let ip = String(value).trim();
if (!ip) return null;
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7);
}
if (ip.includes('%')) {
ip = ip.split('%')[0];
}
return ip.trim() || null;
}
function isPrivateIpv4(ip) {
const parts = ip.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
return false;
}
const [a, b] = parts;
if (a === 10) return true;
if (a === 127) return true;
if (a === 192 && b === 168) return true;
return a === 172 && b >= 16 && b <= 31;
}
function isPrivateIpv6(ip) {
const lower = ip.toLowerCase();
if (lower === '::1') return true;
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
return (
lower.startsWith('fe8') ||
lower.startsWith('fe9') ||
lower.startsWith('fea') ||
lower.startsWith('feb')
); // fe80::/10
}
function isLocalNetwork(ip) {
const normalized = normalizeIp(ip);
if (!normalized) return false;
const version = net.isIP(normalized);
if (version === 4) {
return isPrivateIpv4(normalized);
}
if (version === 6) {
return isPrivateIpv6(normalized);
}
return false;
}
function getSocketIp(socket) {
if (!socket) return null;
const headers = socket.handshake?.headers || {};
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
if (forwarded) return forwarded;
const realIp = headers['x-real-ip'];
if (typeof realIp === 'string' && realIp.trim()) {
return realIp.trim();
}
return (
socket.handshake?.address ||
socket.conn?.remoteAddress ||
socket.request?.connection?.remoteAddress ||
null
);
}
function getRequestIp(req, override) {
const fromOverride = extractForwardedIp(override);
if (fromOverride) return fromOverride;
if (!req) return null;
const headers = req.headers || {};
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
if (forwarded) return forwarded;
const realIp = headers['x-real-ip'];
if (typeof realIp === 'string' && realIp.trim()) {
return realIp.trim();
}
return req.ip || req.connection?.remoteAddress || null;
}
module.exports = {
getSocketIp,
getRequestIp,
isLocalNetwork,
normalizeIp,
};
+4 -4
View File
@@ -9,10 +9,10 @@ const CHARGING_STATE = {
}; };
const OI_MODES = { const OI_MODES = {
0: 'off', 1: 'off',
1: 'passive', 2: 'passive',
2: 'safe', 3: 'safe',
3: 'full', 4: 'full',
}; };
const GROUP100_LAYOUT = [ const GROUP100_LAYOUT = [
+84
View File
@@ -0,0 +1,84 @@
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const { getRole, roleEvents } = require('./roleService');
const { getSocketIp } = require('../helpers/ipResolver');
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const MAX_HISTORY = 200;
const history = [];
function isAdminRole(role) {
return ADMIN_ROLES.has(role);
}
function isAdminSocket(socket) {
return isAdminRole(getRole(socket));
}
function pushEntry(entry) {
history.push(entry);
if (history.length > MAX_HISTORY) {
history.shift();
}
}
function emitToAdmins(event, payload) {
io.sockets.sockets.forEach((socket) => {
if (!isAdminSocket(socket)) return;
socket.emit(event, payload);
});
}
function logAdminEvent({ label, message, ip, meta = null, socketId = null }) {
const entry = {
id: uuidv4(),
ts: Date.now(),
label: label || null,
message: message || '',
ip: ip || null,
meta: meta || null,
socketId: socketId || null,
};
pushEntry(entry);
emitToAdmins('adminlog:entry', entry);
}
function hydrateSocket(socket) {
if (!socket || !isAdminSocket(socket)) return;
socket.emit('adminlog:init', history);
}
io.on('connection', (socket) => {
hydrateSocket(socket);
const ip = getSocketIp(socket);
if (ip) {
logAdminEvent({
label: 'socket',
message: 'Socket connected',
ip,
meta: { role: getRole(socket) },
socketId: socket.id,
});
}
socket.on('disconnect', () => {
const disconnectIp = getSocketIp(socket);
if (!disconnectIp) return;
logAdminEvent({
label: 'socket',
message: 'Socket disconnected',
ip: disconnectIp,
meta: { role: getRole(socket) },
socketId: socket.id,
});
});
});
roleEvents.on('change', ({ socket, role }) => {
if (!socket) return;
if (!isAdminRole(role)) return;
hydrateSocket(socket);
});
module.exports = {
logAdminEvent,
};
+95
View File
@@ -0,0 +1,95 @@
const fs = require('fs');
const path = require('path');
const io = require('../globals/io');
const logger = require('../globals/logger').child('adminReasonService');
const { isAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
const MAX_REASON_LENGTH = 240;
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load admin reason', err.message);
}
cache = null;
}
return cache;
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function normalizeText(input) {
if (typeof input !== 'string') return '';
return input.replace(/\s+/g, ' ').trim();
}
function getAdminReason() {
return loadStore();
}
function setAdminReason(text, meta = {}) {
const clean = normalizeText(text);
if (!clean) {
throw new Error('Reason text required');
}
if (clean.length > MAX_REASON_LENGTH) {
throw new Error(`Reason too long (max ${MAX_REASON_LENGTH} chars)`);
}
const payload = {
text: clean,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
return payload;
}
function clearAdminReason(meta = {}) {
const payload = {
text: null,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
return payload;
}
io.on('connection', (socket) => {
socket.on('adminReason:set', ({ text } = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const result =
text == null || String(text).trim() === ''
? clearAdminReason({ by: socket?.data?.user?.username || socket?.id })
: setAdminReason(text, { by: socket?.data?.user?.username || socket?.id });
cb({ success: true, reason: result });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getAdminReason,
setAdminReason,
clearAdminReason,
MAX_REASON_LENGTH,
};
+1 -9
View File
@@ -1,15 +1,8 @@
const io = require('../globals/io'); const io = require('../globals/io');
const COLORS = {
info: '#2196f3',
success: '#4caf50',
warn: '#f0b651',
error: '#e53935',
};
function sendAlert({ color, title, message, ts = Date.now() }) { function sendAlert({ color, title, message, ts = Date.now() }) {
const payload = { const payload = {
color: color || COLORS.info, color: color || '#2196f3',
title, title,
message, message,
ts, ts,
@@ -19,6 +12,5 @@ function sendAlert({ color, title, message, ts = Date.now() }) {
} }
module.exports = { module.exports = {
COLORS,
sendAlert, sendAlert,
}; };
+82 -6
View File
@@ -2,7 +2,7 @@ const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const logger = require('../globals/logger').child('assignment'); const logger = require('../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('./modeManager'); const { MODES, getMode, modeEvents } = require('./modeManager');
const { roleEvents, getRole, isAdmin } = require('./roleService'); const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('./roleService');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const socketRefs = new Map(); // socketId -> socket const socketRefs = new Map(); // socketId -> socket
@@ -28,14 +28,20 @@ roleEvents.on('change', ({ socket, role }) => {
}); });
modeEvents.on('change', (mode) => { modeEvents.on('change', (mode) => {
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN) {
// release non-admin drivers
for (const [socketId, roverId] of assignments.entries()) { for (const [socketId, roverId] of assignments.entries()) {
const socket = socketRefs.get(socketId); const socket = socketRefs.get(socketId);
if (socket && !isAdmin(socket)) { if (socket && !isAdmin(socket)) {
releaseAssignment(socket, roverId); releaseAssignment(socket, roverId);
} }
} }
} else if (mode === MODES.LOCKDOWN) {
for (const [socketId, roverId] of assignments.entries()) {
const socket = socketRefs.get(socketId);
if (socket && !isLockdownAdmin(socket)) {
releaseAssignment(socket, roverId);
}
}
} }
reassignWaiting(); reassignWaiting();
}); });
@@ -48,12 +54,29 @@ roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
} }
}); });
roverManager.managerEvents.on('private', ({ roverId, open }) => {
if (open) {
reassignWaiting();
} else {
reassignFromRover(roverId);
}
});
roverManager.managerEvents.on('rover', ({ action }) => { roverManager.managerEvents.on('rover', ({ action }) => {
if (action === 'removed' || action === 'upsert') { if (action === 'removed' || action === 'upsert') {
reassignWaiting(); reassignWaiting();
} }
}); });
roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
if (!socketId || !roverId) return;
const socket = socketRefs.get(socketId);
if (!socket) return;
assignments.set(socketId, roverId);
waiting.delete(socketId);
assignmentEvents.emit('update', socketId);
});
function assignSocket(socket) { function assignSocket(socket) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') { if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
return; return;
@@ -62,7 +85,7 @@ function assignSocket(socket) {
if (assignments.has(socket.id)) { if (assignments.has(socket.id)) {
return; return;
} }
const target = pickRover(); const target = pickRover(socket);
if (!target) { if (!target) {
waiting.add(socket.id); waiting.add(socket.id);
logger.info('No rover available, user waiting', socket.id); logger.info('No rover available, user waiting', socket.id);
@@ -129,19 +152,60 @@ function releaseAssignment(socket, roverId) {
assignmentEvents.emit('update', socket.id); assignmentEvents.emit('update', socket.id);
} }
function pickRover() { function forceRelease(roverId, socketId) {
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
if (assignments.get(socketId) === roverId) {
assignments.delete(socketId);
}
waiting.delete(socketId);
if (socket) {
roverManager.releaseControl(roverId, socket);
logger.info('Force released socket from rover', socketId, roverId);
} else {
logger.warn('Force release: socket not found', socketId, roverId);
}
assignmentEvents.emit('update', socketId);
}
function pickRover(socket) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
return null; return null;
} }
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => { const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
if (!rover || rover.locked) return false; if (!rover || rover.locked) return false;
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
if (!access.ok) return false;
return true; return true;
}); });
if (candidates.length === 0) { if (candidates.length === 0) {
return null; return null;
} }
candidates.sort((a, b) => a.drivers.size - b.drivers.size); const dockedRank = (rover) => {
if (!rover) return 0;
if (rover.docked === true) return -1;
if (rover.docked === false) return 1;
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
const docked = sensors?.chargingSources?.homeBase;
if (docked === true) return -1;
if (docked === false) return 1;
return 0;
};
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
candidates.sort((a, b) => {
const aEmpty = idleRank(a);
const bEmpty = idleRank(b);
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
const aDockRank = dockedRank(a);
const bDockRank = dockedRank(b);
if (aEmpty === 1 && aDockRank !== bDockRank) {
return bDockRank - aDockRank;
}
if (a.drivers.size !== b.drivers.size) {
return a.drivers.size - b.drivers.size;
}
return bDockRank - aDockRank;
});
return candidates[0]; return candidates[0];
} }
@@ -161,4 +225,16 @@ function describeAssignment(socketId) {
module.exports = { module.exports = {
assignmentEvents, assignmentEvents,
describeAssignment, describeAssignment,
forceRelease,
getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
if (!socket || !roverId) return;
const previous = assignments.get(socket.id);
if (previous && previous !== roverId && releasePrevious) {
roverManager.releaseControl(previous, socket);
}
assignments.set(socket.id, roverId);
waiting.delete(socket.id);
assignmentEvents.emit('update', socket.id);
},
}; };
+657
View File
@@ -0,0 +1,657 @@
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager');
const turnService = require('./turnService');
const { isVerified } = require('./verificationService');
const videoSessions = require('./videoSessions');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker
const whipOwners = new Map(); // roverId -> socketId
function publishStateChange(roverId) {
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
}
function setState(roverId, next = {}) {
const prev = states.get(roverId) || {};
const merged = {
state: next.state || prev.state || 'idle',
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(),
};
states.set(roverId, merged);
publishStateChange(roverId);
}
function getAudioForwardState() {
const payload = {};
states.forEach((entry, roverId) => {
payload[roverId] = { ...entry };
});
return payload;
}
function ensureRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true });
fs.mkdirSync(uploadsDir, { recursive: true });
}
function sanitizeRoverId(roverId) {
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
}
function sanitizeFileStem(name) {
return String(name || 'upload')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function extFromUpload(name, mime) {
const lowerName = String(name || '').toLowerCase();
const lowerMime = String(mime || '').toLowerCase();
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
}
function ensureVipVerified(socket) {
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
}
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover');
}
if (!turnService.canDrive(roverId, socket)) {
throw new Error('Only the current driver can play audio');
}
}
function ensureFifo(fifoPath) {
try {
const stat = fs.statSync(fifoPath);
if (stat.isFIFO()) return;
fs.unlinkSync(fifoPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
}
}
function forcePublishStreamMode(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (!/[?&]streamid=#!::/.test(value)) return value;
if (/,m=publish\b/.test(value)) return value;
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
const configured = record?.meta?.media?.audioForwardUrl;
if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
}
function resolveForwardPathId(roverId) {
return `${roverId}${streamSuffix}`;
}
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return String(base).replace(/\/+$/, '');
}
}
function buildWhipUrl(pathId) {
const prefix = getMediaPrefix();
if (!prefix) {
throw new Error('Server media base URL missing');
}
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
}
function spawnFfmpeg(roverId, tag, args, options = {}) {
const proc = spawn(ffmpegBin, args, {
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
});
proc.stderr?.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
logger.warn(`${tag} stderr`, { roverId, text });
});
proc.on('error', (err) => {
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
});
return proc;
}
function stopProc(proc, graceMs = 1200) {
if (!proc || proc.killed) return;
try {
proc.kill('SIGTERM');
} catch {
return;
}
setTimeout(() => {
if (!proc.killed) {
try {
proc.kill('SIGKILL');
} catch {
// noop
}
}
}, graceMs);
}
function buildPublisherArgs(fifoPath, outputUrl) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-f',
's16le',
'-ar',
'16000',
'-ac',
'1',
'-i',
fifoPath,
'-c:a',
'libopus',
'-b:a',
'24000',
'-ar:a',
'16000',
'-ac:a',
'1',
'-application',
'lowdelay',
'-frame_duration',
'10',
'-compression_level',
'0',
'-fflags',
'nobuffer',
'-flush_packets',
'1',
'-muxdelay',
'0',
'-muxpreload',
'0',
'-f',
'mpegts',
outputUrl,
];
}
function buildSilenceWriterArgs() {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-f',
'lavfi',
'-i',
'anullsrc=channel_layout=mono:sample_rate=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function buildUploadWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-i',
filePath,
'-vn',
'-af',
'aresample=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function attachWriterPipe(worker, proc) {
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
writer.on('error', (err) => {
const code = err?.code || 'unknown';
if (code !== 'EPIPE') {
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
}
});
proc.stdout.on('error', (err) => {
logger.warn('writer stdout error', {
roverId: worker?.roverId,
code: err?.code || 'unknown',
message: err?.message || String(err),
});
});
proc.stdout.pipe(writer);
proc.on('exit', () => {
writer.destroy();
});
}
function cleanupUploadFile(worker) {
if (!worker?.activeUploadPath) return;
try {
fs.unlinkSync(worker.activeUploadPath);
} catch {
// noop
}
worker.activeUploadPath = null;
}
function stopContentProc(worker) {
if (!worker) return;
if (worker.contentProc) {
stopProc(worker.contentProc);
}
worker.contentProc = null;
worker.contentKind = null;
}
function startSilenceWriter(roverId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeOwnerSocketId = null;
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'silence';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code === 0 || signal === 'SIGTERM') return;
setState(roverId, {
state: 'error',
source: 'silence',
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
setTimeout(() => {
if (workers.has(roverId)) startSilenceWriter(roverId);
}, 300);
});
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
}
function startUploadWriter(roverId, filePath, ownerSocketId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
worker.activeOwnerSocketId = ownerSocketId || null;
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'upload';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, {
state: 'error',
source: 'upload',
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
}
startSilenceWriter(roverId);
});
}
function ensureWorker(roverId) {
if (!serviceEnabled) throw new Error('Audio forward disabled');
if (!roverId) throw new Error('roverId required');
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) throw new Error('Rover offline');
if (workers.has(roverId)) return workers.get(roverId);
ensureRuntimeDir();
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
ensureFifo(fifoPath);
const outputUrl = resolveForwardUrl(roverId);
const keepaliveFd = fs.openSync(fifoPath, 'r+');
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
const worker = {
roverId,
fifoPath,
keepaliveFd,
outputUrl,
publisherProc: publisher,
contentProc: null,
contentKind: null,
writerSeq: 0,
activeOwnerSocketId: null,
activeUploadPath: null,
stopping: false,
};
workers.set(roverId, worker);
publisher.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.publisherProc !== publisher || current.stopping) return;
setState(roverId, {
state: 'error',
source: current.contentKind || 'publish',
error: `publisher exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
});
startSilenceWriter(roverId);
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
return worker;
}
function stopWorker(roverId) {
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, whipOwner);
}
const worker = workers.get(roverId);
if (!worker) return;
worker.stopping = true;
stopContentProc(worker);
cleanupUploadFile(worker);
stopProc(worker.publisherProc);
try {
fs.closeSync(worker.keepaliveFd);
} catch {
// noop
}
try {
fs.unlinkSync(worker.fifoPath);
} catch {
// noop
}
workers.delete(roverId);
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
}
function writeUploadFile(roverId, payload = {}) {
const { name, mime, dataBase64 } = payload || {};
const ext = extFromUpload(name, mime);
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
if (!encoded) throw new Error('Upload payload missing');
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length) throw new Error('Upload decode failed');
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
ensureRuntimeDir();
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
stopWhipForRover(roverId, 'upload_override');
ensureWorker(roverId);
const uploadPath = writeUploadFile(roverId, payload);
startUploadWriter(roverId, uploadPath, ownerSocketId);
}
function stopPlayback(roverId) {
stopWhipForRover(roverId, 'stop_playback');
ensureWorker(roverId);
startSilenceWriter(roverId);
}
function revokeWhipSessionForRover(roverId, ownerSocketId) {
if (!roverId || !ownerSocketId) return;
const pathId = resolveForwardPathId(roverId);
videoSessions.revokeWhere(
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
);
}
function stopWhipForRover(roverId, reason = 'unknown') {
const ownerSocketId = whipOwners.get(roverId);
if (!ownerSocketId) return;
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, ownerSocketId);
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
try {
ensureWorker(roverId);
startSilenceWriter(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'mic-whip', error: err?.message || String(err), startedAt: null });
}
}
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
if (!roverId || !ownerSocketId) return;
if (whipOwners.get(roverId) === ownerSocketId) {
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (!ownerIsDriver || !ownerCanDrive) {
stopWhipForRover(roverId, reason);
}
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (ownerIsDriver && ownerCanDrive) return;
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
startSilenceWriter(roverId);
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return;
if (action === 'removed') {
stopWorker(roverId);
return;
}
if (action === 'upsert' && serviceEnabled) {
if (whipOwners.has(roverId)) {
return;
}
try {
ensureWorker(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
}
}
});
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
if (!socketId || !roverId) return;
if (action === 'remove' || action === 'add') {
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
}
});
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
if (!roverId) return;
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload') return;
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
});
io.on('connection', (socket) => {
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
try {
const roverId = String(payload?.roverId || '').trim();
ensureAudioForwardPermission(socket, roverId);
playUploadedAudio(roverId, payload, socket.id);
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
const worker = workers.get(normalized);
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
throw new Error('Upload playback is owned by another session');
}
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
stopWorker(normalized);
whipOwners.set(normalized, socket.id);
const pathId = resolveForwardPathId(normalized);
revokeWhipSessionForRover(normalized, socket.id);
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
const whipUrl = buildWhipUrl(pathId);
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) !== socket.id) {
throw new Error('WHIP session not owned by this client');
}
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
throw new Error('Mic forwarding is owned by another session');
}
stopWhipForRover(normalized, 'client_stop');
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('disconnect', () => {
workers.forEach((worker, roverId) => {
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
startSilenceWriter(roverId);
});
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId !== socket.id) continue;
stopWhipForRover(roverId, 'socket_disconnect');
}
});
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
};
+153
View File
@@ -0,0 +1,153 @@
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../helpers/configLoader');
const { isAdmin } = require('./roleService');
const roverManager = require('./roverManager');
const { issueCommand } = require('./commandService');
const audioLevelsEvents = new EventEmitter();
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json');
const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
};
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(4, num));
}
function normalizeStore(raw = {}) {
return {
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
};
}
let state = null;
function loadState() {
if (state) return state;
try {
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
state = normalizeStore(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load audio levels store', err.message);
}
state = normalizeStore({});
}
return state;
}
function persistState(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const normalized = normalizeStore(next);
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
state = normalized;
return state;
}
function getAudioLevels() {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
updatedAt: current.updatedAt,
updatedBy: current.updatedBy,
};
}
function emitChange(reason = 'update') {
audioLevelsEvents.emit('change', {
reason,
levels: getAudioLevels(),
});
}
function pushLevelsToRover(roverId) {
if (!roverId) return;
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) return;
try {
issueCommand(roverId, {
type: 'audioLevels',
audioLevels: getAudioLevels(),
});
} catch (err) {
logger.warn('Failed to push audio levels to rover', roverId, err.message);
}
}
function pushLevelsToAllRovers() {
roverManager.rovers.forEach((record, roverId) => {
if (record?.ws) {
pushLevelsToRover(roverId);
}
});
}
function setAudioLevels(input = {}, actor = null) {
const current = loadState();
const next = {
...current,
hornGain: clampGain(input.hornGain, current.hornGain),
ttsGain: clampGain(input.ttsGain, current.ttsGain),
forwardGain: clampGain(input.forwardGain, current.forwardGain),
updatedAt: Date.now(),
updatedBy: actor,
};
persistState(next);
pushLevelsToAllRovers();
emitChange('set');
return getAudioLevels();
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (action === 'upsert' && roverId) {
pushLevelsToRover(roverId);
}
});
io.on('connection', (socket) => {
socket.on('audioLevels:get', (_, cb = () => {}) => {
cb({ success: true, levels: getAudioLevels() });
});
socket.on('audioLevels:set', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const actor = socket?.data?.user?.username || null;
const levels = setAudioLevels(payload || {}, actor);
cb({ success: true, levels });
} catch (err) {
cb({ error: err.message });
}
});
});
loadState();
module.exports = {
getAudioLevels,
setAudioLevels,
pushLevelsToRover,
audioLevelsEvents,
};
+4
View File
@@ -3,6 +3,7 @@ const io = require('../globals/io');
const logger = require('../globals/logger').child('authService'); const logger = require('../globals/logger').child('authService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { clearLockdownTimer } = require('./lockdownGuard'); const { clearLockdownTimer } = require('./lockdownGuard');
const { getMode, MODES } = require('./modeManager');
const { setRole } = require('./roleService'); const { setRole } = require('./roleService');
const config = loadConfig(); const config = loadConfig();
@@ -41,6 +42,9 @@ io.on('connection', (socket) => {
socket.on('auth:login', async ({ username, password }, cb = () => {}) => { socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
try { try {
const admin = await authenticate(username, password); const admin = await authenticate(username, password);
if (getMode() === MODES.LOCKDOWN && !admin.lockdown) {
throw new Error('Lockdown admins only');
}
const role = admin.lockdown ? 'lockdown' : 'admin'; const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id }; socket.data.user = { username: admin.username, discordId: admin.discord_id };
setRole(socket, role); setRole(socket, role);
+188
View File
@@ -0,0 +1,188 @@
const logger = require('../globals/logger').child('batteryManager');
const { publishEvent } = require('./eventBus');
const { managerEvents, lockRover, rovers } = require('./roverManager');
const STATES = {
NORMAL: 'normal',
WARN: 'warn',
URGENT: 'urgent',
DOCKED: 'docked',
CHARGING: 'charging',
FULL: 'full',
LOCKED: 'locked',
};
const WAITING_UNLOCK_MS = 5 * 60 * 1000;
const roverState = new Map(); // roverId -> state snapshot
function getState(roverId) {
if (!roverState.has(roverId)) {
roverState.set(roverId, {
lastPercent: null,
warned: false,
urgent: false,
onDock: false,
dockedCharging: false,
charging: false,
batteryLocked: false,
waitingSince: null,
});
}
return roverState.get(roverId);
}
function shouldLock(record) {
return record.lockReason == null || record.lockReason === 'battery';
}
function handleSensorEvent({ roverId, sensors, batteryState }) {
if (!roverId) return;
const record = rovers.get(roverId);
if (!record) return;
const state = getState(roverId);
const config = record.meta?.battery || {};
const lockable = shouldLock(record);
if (batteryState?.percent != null) {
state.lastPercent = batteryState.percent;
}
if (!state.warned && batteryState?.warnActive) {
state.warned = true;
publishEvent({
source: 'batteryManager',
type: 'battery.warn',
payload: { roverId, batteryState },
});
}
if (!state.urgent && batteryState?.urgentActive) {
state.urgent = true;
publishEvent({
source: 'batteryManager',
type: 'battery.urgent',
payload: { roverId, batteryState },
});
}
const chargingState = sensors?.chargingState?.code;
const chargingSources = sensors?.chargingSources;
const onDock = Boolean(chargingSources?.homeBase);
if (onDock && !state.onDock) {
state.onDock = true;
publishEvent({
source: 'batteryManager',
type: 'battery.docked',
payload: { roverId, batteryState },
});
} else if (!onDock && state.onDock) {
state.onDock = false;
publishEvent({
source: 'batteryManager',
type: 'battery.undocked',
payload: { roverId, batteryState },
});
}
const chargingLabel = sensors?.chargingState?.label?.toLowerCase();
const chargingByLabel =
chargingLabel === 'waiting' || chargingLabel === 'full charging' || chargingLabel === 'trickle charging';
const chargingByCode = chargingState === 2 || chargingState === 3 || chargingState === 4;
const isCharging = chargingByLabel || chargingByCode;
if (isCharging && !state.charging) {
state.charging = true;
publishEvent({
source: 'batteryManager',
type: 'battery.charging.start',
payload: { roverId, batteryState },
});
} else if (!isCharging && state.charging) {
state.charging = false;
publishEvent({
source: 'batteryManager',
type: 'battery.charging.stop',
payload: { roverId, batteryState },
});
}
const dockedCharging = onDock && isCharging;
if (dockedCharging && !state.dockedCharging) {
state.dockedCharging = true;
} else if (!dockedCharging && state.dockedCharging) {
state.dockedCharging = false;
}
const waitingState = chargingState === 4;
if (waitingState && state.waitingSince == null) {
state.waitingSince = Date.now();
} else if (!waitingState && state.waitingSince != null) {
state.waitingSince = null;
}
const waitingLongEnough =
waitingState && state.waitingSince != null && Date.now() - state.waitingSince >= WAITING_UNLOCK_MS;
const warnThreshold =
typeof config?.Warn === 'number' ? config.Warn : batteryState?.warn ?? null;
const fullThreshold =
typeof config?.Full === 'number' ? config.Full : batteryState?.full ?? null;
const chargeMah = batteryState?.charge ?? null;
let waitingPercent = batteryState?.percent ?? null;
if (chargeMah != null && warnThreshold != null && fullThreshold != null && fullThreshold > warnThreshold) {
waitingPercent = (chargeMah - warnThreshold) / (fullThreshold - warnThreshold);
waitingPercent = Math.max(0, Math.min(1, waitingPercent));
}
const isFull =
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
(batteryState?.percent != null && batteryState.percent >= 0.99);
const needsCharge = state.warned;
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
logger.info('Auto-locking rover for charging', { roverId });
lockRover(roverId, true, { reason: 'battery' });
state.batteryLocked = true;
publishEvent({
source: 'batteryManager',
type: 'battery.locked',
payload: { roverId, batteryState },
});
}
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.01;
// const waitingHalfOrMore = true;
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
if (shouldUnlock) {
logger.info('Unlocking rover after charge', {
roverId,
isFull,
docked: dockedCharging,
waitingState,
waitingPercent,
waitedMs: state.waitingSince ? Date.now() - state.waitingSince : null,
});
lockRover(roverId, false, { reason: 'battery' });
state.batteryLocked = false;
state.warned = false;
state.urgent = false;
state.waitingSince = null;
publishEvent({
source: 'batteryManager',
type: 'battery.unlocked',
payload: { roverId, batteryState },
});
}
}
function handleLockEvent({ roverId, locked, reason }) {
const state = getState(roverId);
if (!state) return;
if (!locked) {
state.batteryLocked = false;
state.waitingSince = null;
return;
}
state.batteryLocked = reason === 'battery';
}
managerEvents.on('sensor', handleSensorEvent);
managerEvents.on('lock', handleLockEvent);
+589
View File
@@ -0,0 +1,589 @@
const { v4: uuidv4 } = require('uuid');
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
const io = require('../globals/io');
const logger = require('../globals/logger').child('chatService');
const { publishEvent, subscribe } = require('./eventBus');
const { getRole } = require('./roleService');
const { getMode, MODES } = require('./modeManager');
const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService');
const { getAdminReason } = require('./adminReasonService');
const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
const rateBuckets = new Map(); // socketId -> [timestamps]
const MAX_HISTORY = 100;
const history = [];
// Words in this list are removed from the profanity dataset entirely.
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
const normalizedProfanityAllowlist = new Set(PROFANITY_ALLOWLIST
.filter((term) => typeof term === 'string')
.map((term) => term.trim().toLowerCase())
.filter(Boolean));
const profanityDataset = new DataSet()
.addAll(englishDataset)
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
.build();
const profanityMatcher = new RegExpMatcher({
...profanityDataset,
...englishRecommendedTransformers,
whitelistedTerms: profanityDataset.whitelistedTerms,
});
const DUPLICATE_WINDOW_MS = 15000;
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
const typingBySocket = new Map(); // socketId -> boolean
const TYPING_START_NOTE = 72;
const TYPING_SEND_NOTE = 79;
const TYPING_NOTE_DURATION = 8;
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
let lastAccessNoticeAt = 0;
function withinRateLimit(socketId) {
const now = Date.now();
const entries = rateBuckets.get(socketId) || [];
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
next.push(now);
rateBuckets.set(socketId, next);
return next.length <= RATE_LIMIT_MAX;
}
function hasProfanity(text) {
if (typeof text !== 'string' || !text) return false;
return profanityMatcher.hasMatch(text);
}
function isDuplicate(socketId, text) {
const prev = lastMessageBySocket.get(socketId);
const now = Date.now();
if (!prev) {
lastMessageBySocket.set(socketId, { text, ts: now });
return false;
}
lastMessageBySocket.set(socketId, { text, ts: now });
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
}
function isKeymash(text) {
if (!text) return false;
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
return false;
}
function resolveRoverId(socketId) {
const primary = roverManager.getPrimaryRoverForSocket(socketId);
if (primary) return primary;
const assignment = describeAssignment(socketId);
return assignment?.roverId || null;
}
function resolveRoverColor(roverId) {
if (!roverId) return null;
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
return roverManager.canReplayRoverId(roverId) !== true;
}
function normalizeUserText(raw) {
if (typeof raw !== 'string') return '';
return raw.replace(/\\n/g, '\n');
}
function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
return {
id: uuidv4(),
ts: Date.now(),
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord: Boolean(meta.fromDiscord),
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
roverCtx: meta.roverCtx || null,
text,
tts: meta.tts || null,
system: Boolean(meta.system),
};
}
function isChargingFromSensors(sensors = {}) {
const label = String(sensors?.chargingState?.label || '').toLowerCase();
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
return true;
}
const code = sensors?.chargingState?.code;
return code === 2 || code === 3 || code === 4;
}
function buildRoverCtxSnapshot(roverId) {
if (!roverId) return null;
const key = String(roverId);
const record = roverManager.rovers.get(key);
if (!record) return null;
const sensors = record?.lastSensor?.decoded || {};
const batteryState = record?.batteryState || null;
const { getActiveDrivers } = require('./turnService');
const activeDrivers = getActiveDrivers();
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
const charging = isChargingFromSensors(sensors);
const docked = Boolean(sensors?.chargingSources?.homeBase);
const wheelsOffGround = Boolean(
sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight,
);
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
const latestBumps =
(sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) +
(sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
const light = sensors?.lightBumper || {};
const contactState = docked
? 'clear'
: latestBumps >= 0.5
? 'bumps_recent'
: sensors?.wall ||
light.left ||
light.frontLeft ||
light.centerLeft ||
light.centerRight ||
light.frontRight ||
light.right
? 'wall_brush'
: 'clear';
const hazardState = docked
? 'normal'
: sensors?.virtualWall
? 'virtual_wall_seen'
: sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight
? 'cliff_alert'
: 'normal';
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
const activityScore = Math.max(
0,
Math.min(
100,
baseScore +
(contactState === 'wall_brush' ? 6 : 0) +
(contactState === 'bumps_recent' ? 12 : 0) +
(hazardState !== 'normal' ? 8 : 0) +
(wheelsOffGround ? -20 : 0),
),
);
const activityBand =
activityScore >= 75
? 'intense'
: activityScore >= 50
? 'high'
: activityScore >= 25
? 'medium'
: activityScore >= 8
? 'low'
: 'idle';
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
let statusTag = 'idle';
if (charging) {
statusTag = 'charging';
} else if (docked) {
statusTag = 'docked';
} else if (driverSocketId && moving) {
statusTag = 'driving';
} else if (driverSocketId) {
statusTag = 'active-idle';
}
return {
id: key,
status_tag: statusTag,
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
docked,
charging,
wheels_off_ground: wheelsOffGround,
contact_state: contactState,
hazard_state: hazardState,
mobility_state: mobilityState,
activity_score: activityScore,
activity_band: activityBand,
activity_trend: 'steady',
activity_30s: {
distance_m: latestDistanceM,
turn_deg: latestTurnDeg,
bumps: latestBumps,
},
};
}
function buildTypingPayload(socket, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
const socketId = socket?.id || null;
const fromDiscord = Boolean(meta.fromDiscord);
let typingId = meta.typingId || null;
if (!typingId) {
if (fromDiscord) {
if (meta.discordUserId) {
typingId = `discord:${meta.discordUserId}`;
} else if (meta.discordUserName) {
typingId = `discord:${meta.discordUserName}`;
} else if (meta.nickname) {
typingId = `discord:${meta.nickname}`;
} else {
typingId = 'discord:unknown';
}
} else if (socketId) {
typingId = `socket:${socketId}`;
} else if (meta.nickname) {
typingId = `socket:${meta.nickname}`;
} else {
typingId = 'socket:unknown';
}
}
return {
id: uuidv4(),
ts: Date.now(),
typingId,
isTyping: Boolean(meta.isTyping),
socketId,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord,
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
};
}
function pushHistory(message) {
history.push(message);
if (history.length > MAX_HISTORY) {
history.shift();
}
}
function getRecentMessages(limit = 20, options = {}) {
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
const includeSystem = options?.includeSystem !== false;
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
return source.slice(-safeLimit);
}
function broadcastMessage(message) {
pushHistory(message);
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
}
function broadcastTyping(payload) {
publishEvent({ source: 'chat', type: 'chat:typing', payload });
}
function playTypingNote(roverId, note, socketId) {
if (!roverId) return;
try {
issueCommand(roverId, {
type: 'song',
song: {
notes: [{ note, duration: TYPING_NOTE_DURATION }],
},
});
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Typing tone sent', { roverId, note, socketId });
} catch (err) {
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Typing tone failed', { roverId, note, socketId, error: err.message });
}
}
function normalizeTtsOptions(raw = {}) {
if (!raw || typeof raw !== 'object') return null;
const speak = raw.speak !== false;
if (!speak) return null;
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
if (typeof pitch === 'number') {
pitch = Math.max(0, Math.min(99, pitch));
}
return { speak, engine, voice, pitch };
}
function buildAccessNoticeText(mode, reasonText) {
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
const reason = reasonText ? ` Reason: ${reasonText}` : '';
return `Heads up: the server is in ${label} mode.${reason}`;
}
function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false;
const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
const now = Date.now();
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
lastAccessNoticeAt = now;
return true;
}
function sendSystemMessage(text) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
const message = buildMessage(null, safe, {
nickname: 'The Overseer',
role: 'user',
fromDiscord: false,
system: true,
});
broadcastMessage(message);
return message;
}
function maybeSendAccessNotice(message) {
if (!shouldSendAccessNotice(message)) return;
const reason = getAdminReason()?.text || '';
const mode = getMode();
const notice = buildAccessNoticeText(mode, reason);
sendSystemMessage(notice);
}
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
const role = getRole(socket);
// if (role === 'spectator') {
// cb({ error: 'Spectators cannot chat' });
// return;
// }
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) {
cb({ error: 'Message required' });
return;
}
if (!withinRateLimit(socket.id)) {
cb({ error: 'Slow down' });
return;
}
if (clean.length > 400) {
cb({ error: 'Message too long' });
return;
}
if (hasProfanity(clean)) {
cb({ error: 'Message blocked' });
return;
}
// if (isDuplicate(socket.id, clean)) {
// cb({ error: 'Duplicate message' });
// return;
// }
// if (isKeymash(clean)) {
// cb({ error: 'Message looks like spam' });
// return;
// }
const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, {
fromDiscord: false,
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
tts: ttsOptions,
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
const privateClosed = isPrivateClosedRoverId(message.roverId);
if (privateClosed) {
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
maybeSpeak(socket, message, forcedTts);
cb({ success: true, privateOnly: true });
return;
}
broadcastMessage(message);
maybeSendAccessNotice(message);
maybeSpeak(socket, message, ttsOptions);
cb({ success: true });
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const audio = record?.meta?.audio || {};
const ttsEnabled = Boolean(audio.ttsEnabled);
if (!ttsEnabled) return;
const { isQueuedDriver } = require('./turnService');
if (
!roverManager.canDrive(message.roverId, socket) &&
!isQueuedDriver(message.roverId, socket?.id)
) {
return;
}
try {
issueCommand(message.roverId, {
type: 'tts',
tts: {
text: message.text,
engine: ttsOptions.engine,
voice: ttsOptions.voice,
pitch: ttsOptions.pitch,
speak: true,
},
});
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
} catch (err) {
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
}
}
function sendExternalMessage({
text,
nickname = 'Discord',
role = 'admin',
roverId = null,
discordGuildId = null,
discordGuildName = null,
discordGuildIconUrl = null,
discordChannelId = null,
discordUserId = null,
discordUserName = null,
discordUserAvatarUrl = null,
}) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean || clean.length > 400) {
throw new Error('Message invalid');
}
if (hasProfanity(clean)) {
throw new Error('Message blocked');
}
if (isKeymash(clean)) {
throw new Error('Message looks like spam');
}
if (isPrivateClosedRoverId(roverId)) {
throw new Error('Private rover chat is closed');
}
const message = buildMessage(null, clean, {
nickname,
role,
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
fromDiscord: true,
discordGuildId,
discordGuildName,
discordGuildIconUrl,
discordChannelId,
discordUserId,
discordUserName,
discordUserAvatarUrl,
});
logger.info('External chat message', { roverId, nickname });
broadcastMessage(message);
maybeSendAccessNotice(message);
return message;
}
function sendExternalTyping({
nickname = 'Discord',
role = 'user',
roverId = null,
discordGuildId = null,
discordGuildName = null,
discordGuildIconUrl = null,
discordChannelId = null,
discordUserId = null,
discordUserName = null,
discordUserAvatarUrl = null,
isTyping = true,
}) {
if (isPrivateClosedRoverId(roverId)) {
return null;
}
const payload = buildTypingPayload(null, {
nickname,
role,
roverId,
fromDiscord: true,
discordGuildId,
discordGuildName,
discordGuildIconUrl,
discordChannelId,
discordUserId,
discordUserName,
discordUserAvatarUrl,
isTyping,
});
broadcastTyping(payload);
return payload;
}
io.on('connection', (socket) => {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => {
const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id);
if (isTyping) {
typingBySocket.set(socket.id, true);
if (!wasTyping) {
const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
}
} else {
typingBySocket.delete(socket.id);
}
const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
broadcastTyping(typingPayload);
});
socket.on('disconnect', () => {
if (!typingBySocket.has(socket.id)) return;
typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
broadcastTyping(typingPayload);
});
});
subscribe('chat:message', ({ payload }) => {
if (!payload) return;
io.emit('chat:message', payload);
});
subscribe('chat:typing', ({ payload }) => {
if (!payload) return;
io.emit('chat:typing', payload);
});
module.exports = {
handleIncoming,
sendExternalMessage,
sendExternalTyping,
buildTypingPayload,
sendSystemMessage,
getRecentMessages,
};
+87 -2
View File
@@ -1,9 +1,12 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../globals/io');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { isAdmin, isLockdownAdmin } = require('./roleService');
const logger = require('../globals/logger').child('commandService'); const logger = require('../globals/logger').child('commandService');
const pendingCommands = new Map(); // id -> { roverId } const pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
const driveCooldowns = new Map(); // roverId -> blockedUntil
function issueCommand(roverId, payload) { function issueCommand(roverId, payload) {
const record = roverManager.rovers.get(roverId); const record = roverManager.rovers.get(roverId);
@@ -31,9 +34,27 @@ function handleAck(msg) {
}); });
} }
function getRecentDriveActivity(windowMs, options = {}) {
const now = Date.now();
const results = [];
for (const [roverId, info] of lastDriveActivity.entries()) {
if (!info || now - info.ts > windowMs) continue;
if (options.excludeAdmins && info.isAdmin) continue;
results.push({ roverId, ...info });
}
return results;
}
function setDriveCooldown(roverId, durationMs) {
if (!roverId || !durationMs) return;
driveCooldowns.set(roverId, Date.now() + durationMs);
}
module.exports = { module.exports = {
issueCommand, issueCommand,
handleAck, handleAck,
getRecentDriveActivity,
setDriveCooldown,
}; };
io.on('connection', (socket) => { io.on('connection', (socket) => {
@@ -43,12 +64,59 @@ io.on('connection', (socket) => {
if (!roverId) { if (!roverId) {
throw new Error('roverId required'); throw new Error('roverId required');
} }
if (!roverManager.canDrive(roverId, socket)) { if (!type) {
throw new Error('Not your turn or no control'); throw new Error('type required');
}
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
} }
const payload = data ? { ...data } : {}; const payload = data ? { ...data } : {};
const isRebootCommand = type === 'reboot';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
const isAdminSocket = isAdmin(socket);
if (isRebootCommand && !isAdminSocket) {
throw new Error('Not authorized');
}
if (!isSongCommand && !isRebootCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect && !isAdminSocket) {
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
if (safeDrive) {
payload.driveDirect = safeDrive;
}
const left = Number(payload?.driveDirect?.left);
const right = Number(payload?.driveDirect?.right);
const speed = Math.max(Math.abs(left), Math.abs(right));
const blockedUntil = driveCooldowns.get(roverId);
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
const reason = isLockdownAdmin(socket)
? 'Drive blocked: cooldown'
: 'Drive blocked: safety cooldown';
throw new Error(reason);
}
if (speed > 0) {
let direction = 'turn';
if (left > 0 && right > 0) direction = 'forward';
if (left < 0 && right < 0) direction = 'backward';
lastDriveActivity.set(roverId, {
ts: Date.now(),
socketId: socket.id,
direction,
speed,
isAdmin: isAdminSocket,
});
}
}
const id = issueCommand(roverId, { type, ...payload }); const id = issueCommand(roverId, { type, ...payload });
logger.info('Queued command', socket.id, roverId, type); logger.info('Queued command', socket.id, roverId, type);
try {
const { recordActivity } = require('./turnService');
recordActivity(roverId, socket.id);
} catch (err) {
// best effort; ignore activity update errors
}
reply({ id }); reply({ id });
} catch (err) { } catch (err) {
logger.warn('Command rejected', socket.id, err.message); logger.warn('Command rejected', socket.id, err.message);
@@ -59,3 +127,20 @@ io.on('connection', (socket) => {
socket.on('command', handleCommand); socket.on('command', handleCommand);
socket.on('command:issue', handleCommand); socket.on('command:issue', handleCommand);
}); });
function isSongRawPayload(payload) {
if (!payload) return false;
const raw = payload.raw;
if (!raw) return false;
let bytes = null;
if (Buffer.isBuffer(raw)) {
bytes = raw;
} else if (Array.isArray(raw)) {
bytes = Buffer.from(raw);
} else if (typeof raw === 'string') {
bytes = Buffer.from(raw, 'base64');
}
if (!bytes || bytes.length === 0) return false;
const opcode = bytes[0];
return opcode === 140 || opcode === 141;
}
@@ -0,0 +1,95 @@
const fs = require('fs');
const path = require('path');
const io = require('../globals/io');
const logger = require('../globals/logger').child('communityGoalService');
const { isAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'community-goal.json');
const MAX_GOAL_LENGTH = 240;
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load community goal', err.message);
}
cache = null;
}
return cache;
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function normalizeText(input) {
if (typeof input !== 'string') return '';
return input.replace(/\s+/g, ' ').trim();
}
function getCommunityGoal() {
return loadStore();
}
function setCommunityGoal(text, meta = {}) {
const clean = normalizeText(text);
if (!clean) {
throw new Error('Goal text required');
}
if (clean.length > MAX_GOAL_LENGTH) {
throw new Error(`Goal too long (max ${MAX_GOAL_LENGTH} chars)`);
}
const payload = {
text: clean,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
return payload;
}
function clearCommunityGoal(meta = {}) {
const payload = {
text: null,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
return payload;
}
io.on('connection', (socket) => {
socket.on('communityGoal:set', ({ text } = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const result =
text == null || String(text).trim() === ''
? clearCommunityGoal({ by: socket?.data?.user?.username || socket?.id })
: setCommunityGoal(text, { by: socket?.data?.user?.username || socket?.id });
cb({ success: true, goal: result });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getCommunityGoal,
setCommunityGoal,
clearCommunityGoal,
MAX_GOAL_LENGTH,
};
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
const fs = require('fs');
const path = require('path');
const logger = require('../globals/logger').child('discordGuildStore');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'discord-guilds.json');
const VALID_MODES = new Set(['global', 'private']);
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load guild store', err.message);
}
cache = {};
}
return cache;
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function normalizeMode(input, fallback = 'global') {
const candidate = String(input || '').trim().toLowerCase();
if (VALID_MODES.has(candidate)) return candidate;
return fallback;
}
function listGuildConfigs() {
const store = loadStore();
return Object.values(store);
}
function getGuildConfig(guildId) {
if (!guildId) return null;
const store = loadStore();
return store[String(guildId)] || null;
}
function setGuildConfig(guildId, { channelId, mode, webhookId, webhookToken }) {
if (!guildId) {
throw new Error('guildId required');
}
const store = { ...loadStore() };
const now = Date.now();
const key = String(guildId);
const prev = store[key] || null;
const next = {
guildId: key,
channelId: channelId != null ? String(channelId) : prev?.channelId || null,
mode: normalizeMode(mode, prev?.mode || 'global'),
webhookId: webhookId != null ? String(webhookId) : prev?.webhookId || null,
webhookToken: webhookToken != null ? String(webhookToken) : prev?.webhookToken || null,
createdAt: prev?.createdAt || now,
updatedAt: now,
};
store[key] = next;
saveStore(store);
return next;
}
function removeGuildConfig(guildId) {
if (!guildId) return;
const store = { ...loadStore() };
delete store[String(guildId)];
saveStore(store);
}
module.exports = {
getGuildConfig,
listGuildConfigs,
setGuildConfig,
removeGuildConfig,
normalizeMode,
VALID_MODES,
};
+21
View File
@@ -0,0 +1,21 @@
const { app } = require('../globals/http');
const { renderIndexHtml, renderOgImage } = require('./embedService');
app.get(['/', '/spectate', '/mini'], async (req, res) => {
try {
const html = await renderIndexHtml(req);
res.type('html').send(html);
} catch (err) {
res.status(500).send('Failed to render page');
}
});
app.get('/og/preview.png', async (req, res) => {
try {
const buffer = await renderOgImage();
res.set('Cache-Control', 'public, max-age=60');
res.type('png').send(buffer);
} catch (err) {
res.status(500).send('Failed to render embed image');
}
});
+275
View File
@@ -0,0 +1,275 @@
const path = require('path');
const fsp = require('fs/promises');
const sharp = require('sharp');
const { getMode } = require('./modeManager');
const roverManager = require('./roverManager');
const { getActiveDrivers, getTurnQueues } = require('./turnService');
const { getRoomCameras } = require('./roomCameraService');
const { getRoomCameraState } = require('./roomCameraSnapshotService');
const { loadConfig } = require('../helpers/configLoader');
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', 'public', 'index.html');
const BITMAP_PATH = path.join(__dirname, '..', '..', 'public', 'bitmap.png');
const OG_WIDTH = 1200;
const OG_HEIGHT = 630;
const BASE_BG = { r: 8, g: 12, b: 22 };
let cachedIndexHtml = null;
let cachedIndexMtimeMs = 0;
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeXml(value) {
return escapeHtml(value);
}
async function loadIndexHtml() {
const stat = await fsp.stat(INDEX_HTML_PATH);
if (!cachedIndexHtml || stat.mtimeMs !== cachedIndexMtimeMs) {
cachedIndexHtml = await fsp.readFile(INDEX_HTML_PATH, 'utf8');
cachedIndexMtimeMs = stat.mtimeMs;
}
return cachedIndexHtml;
}
function getBaseUrl(req) {
const forwardedProto = req.headers['x-forwarded-proto'];
const forwardedHost = req.headers['x-forwarded-host'];
const proto = forwardedProto ? forwardedProto.split(',')[0].trim() : req.protocol || 'http';
const host = forwardedHost || req.headers.host || 'localhost';
return `${proto}://${host}`;
}
function getPrimaryRoomCamera() {
const cameras = getRoomCameras();
if (!cameras.length) return null;
return cameras[0];
}
function sumQueueCounts(turnQueues = {}) {
return Object.values(turnQueues).reduce((sum, entry) => {
const size = Array.isArray(entry?.queue) ? entry.queue.length : 0;
return sum + size;
}, 0);
}
function getPublicRovers() {
return roverManager
.getRoster()
.filter((rover) => roverManager.canReplayRoverId(rover.id));
}
function buildEmbedCopy(state, camera) {
const roversOnline = state?.rovers?.length || 0;
const visibleRoverIds = new Set((state?.rovers || []).map((rover) => String(rover.id)));
const driverCount = Object.entries(state?.activeDrivers || {}).reduce((count, [roverId, socketId]) => {
if (!socketId) return count;
return visibleRoverIds.has(String(roverId)) ? count + 1 : count;
}, 0);
const mode = state?.mode || 'open';
const modeLabel = {
open: 'open drive',
turns: 'turns mode',
admin: 'admin mode',
lockdown: 'locked',
}[mode] || mode;
let title = 'Multi Roomba Rover';
if (mode === 'lockdown') {
title = 'Private mode is on';
} else if (roversOnline === 0) {
title = 'Rovers offline - check back soon';
} else if (driverCount > 0) {
title = 'Rovers in use - drive a rover';
} else if (mode === 'turns') {
title = 'Controls open - jump in';
} else {
title = 'Controls open - drive a rover';
}
const descriptionParts = [];
descriptionParts.push(`${roversOnline} rover${roversOnline === 1 ? '' : 's'} online`);
if (driverCount > 0) {
descriptionParts.push(`${driverCount} driving`);
} else {
descriptionParts.push('no active drivers');
}
if (mode === 'lockdown') {
descriptionParts.push('privacy mode');
} else {
descriptionParts.push(modeLabel);
}
const description = descriptionParts.join(' | ');
const statsParts = [
`${roversOnline} online`,
driverCount > 0 ? `${driverCount} driving` : 'no drivers',
];
if (mode === 'lockdown') {
statsParts.push('privacy mode');
} else {
statsParts.push(modeLabel);
}
const cameraLabel = camera?.name || camera?.id || 'room cam';
return {
title,
description,
subtitle: 'Control a live rover from your browser',
stats: statsParts.join(' | '),
cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`,
};
}
function buildMetaTags({ title, description, imageUrl, pageUrl }) {
const safeTitle = escapeHtml(title);
const safeDescription = escapeHtml(description);
const safeImage = escapeHtml(imageUrl);
const safeUrl = escapeHtml(pageUrl);
return [
'<!-- embed meta -->',
`<meta name="description" content="${safeDescription}" />`,
`<meta property="og:title" content="${safeTitle}" />`,
`<meta property="og:description" content="${safeDescription}" />`,
'<meta property="og:type" content="website" />',
`<meta property="og:url" content="${safeUrl}" />`,
`<meta property="og:image" content="${safeImage}" />`,
'<meta property="og:image:width" content="1200" />',
'<meta property="og:image:height" content="630" />',
`<meta property="og:site_name" content="${safeTitle}" />`,
'<meta name="twitter:card" content="summary_large_image" />',
`<meta name="twitter:title" content="${safeTitle}" />`,
`<meta name="twitter:description" content="${safeDescription}" />`,
`<meta name="twitter:image" content="${safeImage}" />`,
'<!-- /embed meta -->',
].join('\n ');
}
async function renderIndexHtml(req) {
const baseUrl = getBaseUrl(req);
const state = {
mode: getMode(),
rovers: getPublicRovers(),
activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(),
};
const config = loadConfig();
const pageTitle = config?.site?.title || 'Roomba Rover';
const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera);
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`;
const metaBlock = buildMetaTags({
title: pageTitle,
description: copy.description,
imageUrl,
pageUrl,
});
let html = await loadIndexHtml();
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`);
if (html.includes('<!-- embed meta -->')) {
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
} else {
html = html.replace('</head>', ` ${metaBlock}\n </head>`);
}
return html;
}
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
const titleSize = 64;
const subtitleSize = 34;
const statsSize = 30;
const labelSize = 26;
const badgeText = hasFrame ? 'Room cam live' : 'Room cam';
return `
<svg width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(0,0,0,0)" />
<stop offset="50%" stop-color="rgba(0,0,0,0.35)" />
<stop offset="100%" stop-color="rgba(0,0,0,0.85)" />
</linearGradient>
</defs>
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" />
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018">
${escapeXml(badgeText)}
</text>
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
${escapeXml(title)}
</text>
<text x="64" y="455" font-family="DejaVu Sans, Arial, sans-serif" font-size="${subtitleSize}" font-weight="500" fill="#d6e4ff">
${escapeXml(subtitle)}
</text>
<text x="64" y="505" font-family="DejaVu Sans, Arial, sans-serif" font-size="${statsSize}" font-weight="600" fill="#7ef9b2">
${escapeXml(stats)}
</text>
<text x="64" y="552" font-family="DejaVu Sans, Arial, sans-serif" font-size="${labelSize}" font-weight="500" fill="#a7b6d8">
${escapeXml(cameraLabel)}
</text>
</svg>`;
}
async function renderOgImage() {
const state = {
mode: getMode(),
rovers: getPublicRovers(),
activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(),
};
const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera);
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
const frame = cameraState?.frame || null;
const hasFrame = Boolean(frame);
const base = frame
? sharp(frame).resize(OG_WIDTH, OG_HEIGHT, { fit: 'cover' })
: sharp({
create: {
width: OG_WIDTH,
height: OG_HEIGHT,
channels: 3,
background: BASE_BG,
},
});
const overlaySvg = Buffer.from(
buildOverlaySvg({
title: copy.title,
subtitle: copy.subtitle,
stats: copy.stats,
cameraLabel: copy.cameraLabel,
hasFrame,
}),
);
const composite = [{ input: overlaySvg, top: 0, left: 0 }];
try {
const logo = await fsp.readFile(BITMAP_PATH);
const logoPng = await sharp(logo).resize(88, 88).png().toBuffer();
composite.push({ input: logoPng, top: 42, left: OG_WIDTH - 130 });
} catch (err) {
// Optional logo; ignore if missing.
}
return base.composite(composite).png().toBuffer();
}
module.exports = {
renderIndexHtml,
renderOgImage,
};
+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,
};
+126
View File
@@ -0,0 +1,126 @@
const fsp = require('fs/promises');
const path = require('path');
const roverManager = require('./roverManager');
const { getRoomCameras } = require('./roomCameraService');
const { getRoomCameraState } = require('./roomCameraSnapshotService');
const { getReplaySources } = require('./replaySourceService');
const { replaySegmentsDir, segmentSeconds, bufferSeconds } = require('./replaySegmentManager');
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_STALE_MS = 5000;
let latest = {
updatedAt: Date.now(),
replay: { sources: [], readyCount: 0, totalCount: 0 },
snapshots: { rovers: [], rooms: [] },
};
async function collectReplayHealth(now) {
const neededCount = Math.max(1, Math.ceil(20000 / (segmentSeconds * 1000)));
const sources = getReplaySources();
const list = [];
let readyCount = 0;
for (const source of sources) {
const key = `${source.type}__${source.id}`;
const dir = path.join(replaySegmentsDir, key);
let lastSegmentAt = null;
let recentCount = 0;
try {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
const stat = await fsp.stat(path.join(dir, entry.name));
if (stat.mtimeMs > (lastSegmentAt || 0)) {
lastSegmentAt = stat.mtimeMs;
}
if (now - stat.mtimeMs <= bufferSeconds * 1000) {
recentCount += 1;
}
}
} catch {
// directory missing or unreadable
}
const ready = recentCount >= neededCount;
if (ready) readyCount += 1;
list.push({
type: source.type,
id: source.id,
label: source.label || `${source.type}:${source.id}`,
recentCount,
neededCount,
lastSegmentAt,
ready,
});
}
return { sources: list, readyCount, totalCount: list.length };
}
async function collectSnapshotHealth(now) {
const rovers = roverManager.getRoster().map((rover) => ({
id: String(rover.id),
name: rover.name || rover.id,
}));
const roverSnapshots = [];
for (const rover of rovers) {
const filePath = path.join(ROVER_SNAPSHOT_DIR, `${rover.id}.jpg`);
let exists = false;
let size = 0;
let updatedAt = null;
try {
const stat = await fsp.stat(filePath);
exists = true;
size = stat.size;
updatedAt = stat.mtimeMs;
} catch {
// missing snapshot
}
const ageMs = updatedAt ? now - updatedAt : null;
const stale = ageMs != null ? ageMs > ROVER_SNAPSHOT_STALE_MS : true;
roverSnapshots.push({
id: rover.id,
name: rover.name,
exists,
size,
updatedAt,
ageMs,
stale,
});
}
const roomSnapshots = getRoomCameras().map((camera) => {
const state = getRoomCameraState(camera.id);
const updatedAt = state?.ts || null;
const ageMs = updatedAt ? now - updatedAt : null;
const stale = ageMs != null ? ageMs > ROOM_CAMERA_STALE_MS : true;
return {
id: camera.id,
name: camera.name || camera.id,
updatedAt,
ageMs,
error: state?.error || null,
stale,
};
});
return { rovers: roverSnapshots, rooms: roomSnapshots };
}
async function refreshHealth() {
const now = Date.now();
const replay = await collectReplayHealth(now);
const snapshots = await collectSnapshotHealth(now);
latest = { updatedAt: now, replay, snapshots };
}
refreshHealth();
setInterval(refreshHealth, HEALTH_INTERVAL_MS);
function getHealthSnapshot() {
return latest;
}
module.exports = {
getHealthSnapshot,
};
+337
View File
@@ -0,0 +1,337 @@
const EventEmitter = require('events');
const WebSocket = require('ws');
const { createConnection, subscribeEntities, callService, Auth } = require('home-assistant-js-websocket');
const io = require('../globals/io');
const logger = require('../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../helpers/configLoader');
const { getMode } = require('./modeManager');
const { isAdmin, isLockdownAdmin } = require('./roleService');
// home-assistant-js-websocket expects a global WebSocket in Node.
if (!global.WebSocket) {
global.WebSocket = WebSocket;
}
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const events = new EventEmitter();
const entityConfig = new Map(); // entityId -> { id, name, type }
const entityState = new Map(); // entityId -> normalized state
let connection = null;
let unsubscribeEntities = null;
let reconnectTimer = null;
let connected = false;
const enabled = Boolean(haConfig?.url && haConfig?.token);
function buildAuth() {
const token = haConfig?.token?.trim();
const url = haConfig?.url?.trim();
if (!token || !url) {
throw new Error('Home Assistant url/token missing');
}
// Long-lived tokens last 10 years; set a far future expiry to avoid refresh attempts.
return new Auth(
{
hassUrl: url.replace(/\/+$/, ''),
access_token: token,
expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10,
refresh_token: null,
clientId: 'multiroomba-rover',
},
null,
);
}
function inferType(entityId, explicitType) {
if (explicitType === 'light' || explicitType === 'switch') {
return explicitType;
}
const domain = String(entityId || '').split('.')[0];
if (domain === 'light') return 'light';
return 'switch';
}
function normalizeConfigEntry(entry) {
if (!entry) return null;
const id = entry.id || entry.entityId || entry.entity_id;
if (!id) return null;
const type = inferType(id, entry.type);
const name = entry.name || null;
return { id: String(id), name, type };
}
function loadEntityConfig() {
entityConfig.clear();
const list = Array.isArray(haConfig?.entities) ? haConfig.entities : [];
list.forEach((entry) => {
const normalized = normalizeConfigEntry(entry);
if (normalized) {
entityConfig.set(normalized.id, normalized);
if (!entityState.has(normalized.id)) {
entityState.set(normalized.id, buildState(normalized, null));
}
}
});
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
}
function buildState(meta, raw) {
if (!meta) return null;
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
const supportedColorModes = Array.isArray(raw?.attributes?.supported_color_modes)
? raw.attributes.supported_color_modes.map((mode) => String(mode))
: [];
const rgbColor = Array.isArray(raw?.attributes?.rgb_color) ? raw.attributes.rgb_color : null;
const hsColor = Array.isArray(raw?.attributes?.hs_color) ? raw.attributes.hs_color : null;
const supportsColor =
meta.type === 'light' &&
(rgbColor ||
hsColor ||
supportedColorModes.some((mode) => mode === 'hs' || mode === 'rgb' || mode === 'xy'));
if (!raw) {
return {
id: meta.id,
name,
type: meta.type,
state: 'unknown',
available: false,
lastChanged: null,
lastUpdated: null,
supportedColorModes,
colorMode: null,
rgbColor: null,
hsColor: null,
supportsColor,
};
}
const rawState = raw.state;
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
return {
id: meta.id,
name,
type: meta.type,
state,
available: !unavailable,
lastChanged: raw.last_changed || null,
lastUpdated: raw.last_updated || null,
supportedColorModes,
colorMode: raw?.attributes?.color_mode || null,
rgbColor,
hsColor,
supportsColor,
};
}
function emitUpdate() {
events.emit('update', getState());
}
function emitStatus() {
events.emit('status', getState());
}
function handleEntitySnapshot(snapshot = {}) {
let changed = false;
entityConfig.forEach((meta, id) => {
const raw = snapshot[id];
const next = buildState(meta, raw);
const prev = entityState.get(id);
if (
!prev ||
prev.state !== next.state ||
prev.available !== next.available ||
prev.lastChanged !== next.lastChanged
) {
entityState.set(id, next);
changed = true;
}
});
if (changed) {
emitUpdate();
}
}
function teardownConnection() {
if (unsubscribeEntities) {
try {
unsubscribeEntities();
} catch (err) {
logger.warn('Failed to unsubscribe entity stream', err.message);
}
}
unsubscribeEntities = null;
if (connection) {
try {
connection.close();
} catch (err) {
logger.warn('Error closing Home Assistant connection', err.message);
}
}
connection = null;
const wasConnected = connected;
connected = false;
if (wasConnected) {
emitStatus();
}
}
function scheduleReconnect(delayMs = 5000) {
if (!enabled) return;
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delayMs);
}
async function connect() {
if (!enabled) {
logger.info('Home Assistant integration disabled; missing url/token in config');
return;
}
if (connection) {
return;
}
try {
const auth = buildAuth();
connection = await createConnection({ auth, setupRetry: 0 });
connected = true;
emitStatus();
logger.info('Connected to Home Assistant');
unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot);
connection.addEventListener('disconnected', () => {
logger.warn('Home Assistant connection lost');
teardownConnection();
scheduleReconnect();
});
} catch (err) {
logger.warn('Home Assistant connection failed', err.message);
teardownConnection();
scheduleReconnect();
}
}
async function setEntityState(entityId, desiredState) {
if (!enabled) {
throw new Error('Home Assistant not configured');
}
const meta = entityConfig.get(entityId);
if (!meta) {
throw new Error('Unknown Home Assistant entity');
}
if (!connection) {
throw new Error('Home Assistant not connected');
}
const nextState = desiredState === 'on' ? 'on' : 'off';
const domain = meta.type === 'light' ? 'light' : 'switch';
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
await callService(connection, domain, service, { entity_id: entityId });
logger.info('Issued Home Assistant command', { entityId, domain, service });
}
async function toggleEntity(entityId) {
const current = entityState.get(entityId);
const nextState = current?.state === 'on' ? 'off' : 'on';
return setEntityState(entityId, nextState);
}
async function setLightColor(entityId, rgbColor) {
if (!enabled) {
throw new Error('Home Assistant not configured');
}
const meta = entityConfig.get(entityId);
if (!meta || meta.type !== 'light') {
throw new Error('Home Assistant light required');
}
if (!connection) {
throw new Error('Home Assistant not connected');
}
if (!Array.isArray(rgbColor) || rgbColor.length !== 3) {
throw new Error('rgbColor required');
}
const normalized = rgbColor.map((value) => {
const next = Number(value);
if (Number.isNaN(next)) return 0;
return Math.max(0, Math.min(255, Math.round(next)));
});
await callService(connection, 'light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
}
function getState() {
const entities = Array.from(entityConfig.values()).map(
(meta) => entityState.get(meta.id) || buildState(meta, null),
);
return { enabled, connected, entities };
}
loadEntityConfig();
connect();
io.on('connection', (socket) => {
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
const mode = getMode();
if (
(mode === 'admin' && isAdmin(socket) !== true) ||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
try {
if (!entityId) throw new Error('entityId required');
await toggleEntity(entityId);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
const mode = getMode();
if (
(mode === 'admin' && isAdmin(socket) !== true) ||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
try {
if (!entityId) throw new Error('entityId required');
await setEntityState(entityId, state);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('homeAssistant:lightColor', async ({ entityId, rgbColor } = {}, cb = () => {}) => {
const mode = getMode();
if (
(mode === 'admin' && isAdmin(socket) !== true) ||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
try {
if (!entityId) throw new Error('entityId required');
await setLightColor(entityId, rgbColor);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getState,
toggleEntity,
setEntityState,
setLightColor,
homeAssistantEvents: events,
};
File diff suppressed because it is too large Load Diff
-11
View File
@@ -22,17 +22,6 @@ function enforceLockdown() {
} }
} }
io.on('connection', (socket) => {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
socket.data.lockdownTimer = setTimeout(() => {
if (!isLockdownAdmin(socket)) {
disconnectForLockdown(socket);
}
}, 10000);
socket.once('disconnect', () => clearLockdownTimer(socket));
}
});
module.exports = { module.exports = {
enforceLockdown, enforceLockdown,
disconnectForLockdown, disconnectForLockdown,
+18 -7
View File
@@ -1,7 +1,9 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const { sendAlert, COLORS } = require('./alertService'); const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#ff9800';
const { isAdmin, isLockdownAdmin } = require('./roleService'); const { isAdmin, isLockdownAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const MODES = { const MODES = {
OPEN: 'open', OPEN: 'open',
@@ -10,21 +12,25 @@ const MODES = {
LOCKDOWN: 'lockdown', LOCKDOWN: 'lockdown',
}; };
let currentMode = MODES.OPEN; let currentMode = MODES.ADMIN;
const modeEvents = new EventEmitter(); const modeEvents = new EventEmitter();
function canChangeMode(socket, nextMode) { function canChangeMode(socket, nextMode) {
if (nextMode === MODES.LOCKDOWN) { if (currentMode === MODES.LOCKDOWN && nextMode !== MODES.LOCKDOWN) {
return isLockdownAdmin(socket); return isLockdownAdmin(socket);
} }
if (nextMode === MODES.LOCKDOWN) {
return isAdmin(socket);
}
return isAdmin(socket); return isAdmin(socket);
} }
function setMode(nextMode, socket) { function setMode(nextMode, socket, options = {}) {
if (!Object.values(MODES).includes(nextMode)) { if (!Object.values(MODES).includes(nextMode)) {
throw new Error(`Unknown mode ${nextMode}`); throw new Error(`Unknown mode ${nextMode}`);
} }
if (!canChangeMode(socket, nextMode)) { const force = Boolean(options.force);
if (!force && !canChangeMode(socket, nextMode)) {
throw new Error('Not authorized to change mode'); throw new Error('Not authorized to change mode');
} }
if (currentMode === nextMode) { if (currentMode === nextMode) {
@@ -32,10 +38,15 @@ function setMode(nextMode, socket) {
} }
currentMode = nextMode; currentMode = nextMode;
sendAlert({ sendAlert({
color: COLORS.info, color: ALERT_COLOR,
title: 'Mode Changed', title: 'Mode Changed',
message: `Server mode set to ${nextMode}`, message: `Server mode set to ${nextMode}`,
}); });
publishEvent({
source: 'modeManager',
type: 'mode.changed',
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
});
modeEvents.emit('change', currentMode); modeEvents.emit('change', currentMode);
io.emit('mode', { mode: currentMode }); io.emit('mode', { mode: currentMode });
return currentMode; return currentMode;
@@ -58,7 +69,7 @@ io.on('connection', (socket) => {
try { try {
setMode(mode, socket); setMode(mode, socket);
} catch (err) { } catch (err) {
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message }); sendAlert({ color: ALERT_COLOR, title: 'Mode change failed', message: err.message });
} }
}); });
}); });
+49
View File
@@ -1 +1,50 @@
const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const logger = require('../globals/logger').child('nicknameService');
const nicknameEvents = new EventEmitter();
function sanitizeNickname(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
const sanitized = trimmed.replace(/\*/g, 'nope');
return sanitized.slice(0, 32);
}
function getNickname(socket) {
return socket?.data?.nickname || '';
}
function setNickname(socket, nickname) {
if (!socket) return null;
const value = sanitizeNickname(nickname);
if (!value) {
throw new Error('Nickname required');
}
socket.data = socket.data || {};
if (socket.data.nickname === value) {
return value;
}
socket.data.nickname = value;
nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
logger.info('Nickname set', { socketId: socket.id, nickname: value });
return value;
}
io.on('connection', (socket) => {
socket.on('nickname:set', ({ nickname } = {}, cb = () => {}) => {
try {
const value = setNickname(socket, nickname);
cb({ success: true, nickname: value });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getNickname,
setNickname,
nicknameEvents,
};
+219
View File
@@ -0,0 +1,219 @@
const { execFile } = require('child_process');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const { promisify } = require('util');
const logger = require('../globals/logger').child('replayBuild');
const { replaySegmentsDir, segmentSeconds } = require('./replaySegmentManager');
const execFileAsync = promisify(execFile);
const REPLAY_DURATION_MS = 20000;
const REPLAY_FPS = 15;
const MAX_REPLAY_WIDTH = 1280;
const MAX_REPLAY_HEIGHT = 720;
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
function buildGridLayout(count) {
const cols = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / cols);
return { cols, rows };
}
function clampEven(value) {
return Math.max(2, Math.floor(value / 2) * 2);
}
function buildScalePadFilter(tileWidth, tileHeight) {
return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
}
async function probeMaxFrameSize(paths) {
let maxWidth = 0;
let maxHeight = 0;
for (const filePath of paths) {
try {
const { stdout } = await execFileAsync('ffprobe', [
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height',
'-of',
'csv=p=0',
filePath,
]);
const [widthRaw, heightRaw] = stdout.trim().split(',');
const width = Number(widthRaw);
const height = Number(heightRaw);
if (Number.isFinite(width) && Number.isFinite(height)) {
maxWidth = Math.max(maxWidth, width);
maxHeight = Math.max(maxHeight, height);
}
} catch (err) {
logger.warn('Failed to probe replay clip size', err.message);
}
}
return { maxWidth, maxHeight };
}
async function listLatestSegments(sourceKey, neededCount) {
const dir = path.join(replaySegmentsDir, sourceKey);
const entries = await fsp.readdir(dir, { withFileTypes: true });
const files = [];
const now = Date.now();
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
const filePath = path.join(dir, entry.name);
const stat = await fsp.stat(filePath);
if (stat.size < 16 * 1024) {
continue;
}
if (now - stat.mtimeMs < segmentSeconds * 1000) {
continue;
}
files.push({ filePath, mtimeMs: stat.mtimeMs });
}
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
return files.slice(-neededCount).map((file) => file.filePath);
}
async function buildReplayVideo({ sources = [] } = {}) {
if (!sources.length) {
throw new Error('No replay sources selected');
}
const segmentCount = Math.max(1, Math.ceil(REPLAY_DURATION_MS / (segmentSeconds * 1000)));
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
try {
const clipPaths = [];
const usedSources = [];
const missingSources = [];
for (let i = 0; i < sources.length; i += 1) {
const source = sources[i];
const key = `${source.type}__${source.id}`;
let segmentPaths;
try {
segmentPaths = await listLatestSegments(key, segmentCount);
} catch (err) {
missingSources.push({ ...source, reason: err.message || 'missing segments' });
continue;
}
if (segmentPaths.length < segmentCount) {
missingSources.push({
...source,
reason: `only ${segmentPaths.length}/${segmentCount} segments available`,
});
continue;
}
const concatPath = path.join(tmpDir, `concat-${clipPaths.length}.txt`);
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
await fsp.writeFile(concatPath, concatBody);
const clipPath = path.join(tmpDir, `clip-${clipPaths.length}.mp4`);
await execFileAsync('ffmpeg', [
'-hide_banner',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
concatPath,
'-c',
'copy',
clipPath,
]);
clipPaths.push(clipPath);
usedSources.push(source);
}
if (!clipPaths.length) {
throw new Error('No replay segments available for selected sources');
}
if (missingSources.length) {
logger.warn('Replay sources missing segments', {
missing: missingSources.map((source) => `${source.type}:${source.id}`),
});
}
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
const layout = buildGridLayout(clipPaths.length);
let tileWidth = maxWidth || 640;
let tileHeight = maxHeight || 360;
let outputWidth = tileWidth * layout.cols;
let outputHeight = tileHeight * layout.rows;
if (outputWidth > MAX_REPLAY_WIDTH || outputHeight > MAX_REPLAY_HEIGHT) {
const scale = Math.min(MAX_REPLAY_WIDTH / outputWidth, MAX_REPLAY_HEIGHT / outputHeight);
tileWidth *= scale;
tileHeight *= scale;
outputWidth = tileWidth * layout.cols;
outputHeight = tileHeight * layout.rows;
}
tileWidth = clampEven(tileWidth);
tileHeight = clampEven(tileHeight);
outputWidth = clampEven(tileWidth * layout.cols);
outputHeight = clampEven(tileHeight * layout.rows);
const inputArgs = [];
const filterParts = [];
const layoutParts = [];
for (let i = 0; i < clipPaths.length; i += 1) {
inputArgs.push('-i', clipPaths[i]);
filterParts.push(`[${i}:v]${buildScalePadFilter(tileWidth, tileHeight)}[v${i}]`);
const x = (i % layout.cols) * tileWidth;
const y = Math.floor(i / layout.cols) * tileHeight;
layoutParts.push(`${x}_${y}`);
}
if (clipPaths.length === 1) {
filterParts.push('[v0]null[v]');
} else {
filterParts.push(
`${clipPaths.map((_, i) => `[v${i}]`).join('')}` +
`xstack=inputs=${clipPaths.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
);
}
const durationSec = Math.max(1, REPLAY_DURATION_MS / 1000);
const targetBitrateKbps = Math.max(300, Math.floor((REPLAY_MAX_BYTES * 8) / durationSec / 1000));
const maxrateKbps = Math.floor(targetBitrateKbps * 1.1);
const bufsizeKbps = Math.floor(targetBitrateKbps * 2);
const outPath = path.join(tmpDir, 'replay.mp4');
await execFileAsync('ffmpeg', [
'-y',
'-hide_banner',
'-loglevel',
'error',
...inputArgs,
'-filter_complex',
filterParts.join(';'),
'-map',
'[v]',
'-r',
String(REPLAY_FPS),
'-c:v',
'libx264',
'-b:v',
`${targetBitrateKbps}k`,
'-maxrate',
`${maxrateKbps}k`,
'-bufsize',
`${bufsizeKbps}k`,
'-pix_fmt',
'yuv420p',
outPath,
]);
const buffer = await fsp.readFile(outPath);
return { buffer, usedSources, missingSources };
} finally {
try {
await fsp.rm(tmpDir, { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to cleanup replay temp dir', err.message);
}
}
}
module.exports = {
buildReplayVideo,
};
+325
View File
@@ -0,0 +1,325 @@
const { spawn } = require('child_process');
const fsp = require('fs/promises');
const path = require('path');
const logger = require('../globals/logger').child('replaySegments');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const roverManager = require('./roverManager');
const SEGMENT_DIR = process.env.REPLAY_SEGMENT_DIR || '/var/lib/replay-segments';
const SEGMENT_SECONDS = 2;
const BUFFER_SECONDS = 40;
const CLEANUP_INTERVAL_MS = 20000;
const FPS = 15;
const SCALE_WIDTH = 640;
const MAX_BYTES = Number.parseInt(process.env.REPLAY_SEGMENT_MAX_BYTES || '0', 10);
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const ROVER_SNAPSHOT_FPS = 3;
const recorders = new Map(); // key -> { proc, source }
let cleanupTimer = null;
function sourceKey(source) {
return `${source.type}__${source.id}`;
}
function getRoomCameraStream(camera) {
if (camera.streamUrl) return camera.streamUrl;
const url = String(camera.url || '');
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
return url;
}
return null;
}
function listSources() {
const rooms = getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
streamUrl: getRoomCameraStream(camera),
}));
const rovers = roverManager.getRoster().map((rover) => ({
type: 'rover',
id: String(rover.id),
label: rover.name || rover.id,
}));
return [...rooms, ...rovers];
}
function buildInputUrl(source) {
if (source.type === 'room') {
return source.streamUrl || null;
}
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(source.id)}`;
}
async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true });
}
function buildOutputPattern(key) {
return path.join(SEGMENT_DIR, key, '%Y%m%d-%H%M%S.mp4');
}
function spawnRecorder(source) {
const key = sourceKey(source);
const inputUrl = buildInputUrl(source);
if (!inputUrl) {
logger.warn('Replay recorder missing input URL', { key, source });
return;
}
const outPattern = buildOutputPattern(key);
const outputSnapshot = source.type === 'rover'
? path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`)
: null;
ensureDir(path.dirname(outPattern))
.then(async () => {
if (outputSnapshot) {
await ensureDir(ROVER_SNAPSHOT_DIR);
}
const args = [
'-hide_banner',
'-loglevel',
'info',
'-y',
'-fflags',
'nobuffer',
'-flags',
'low_delay',
'-i',
inputUrl,
];
if (outputSnapshot) {
args.push(
'-filter_complex',
`[0:v]fps=${FPS},scale=${SCALE_WIDTH}:-1[vseg];` +
`[0:v]fps=${ROVER_SNAPSHOT_FPS},scale=${SCALE_WIDTH}:-1[vjpg]`,
'-map',
'[vseg]',
'-an',
'-c:v:0',
'libx264',
'-preset',
'veryfast',
'-tune',
'zerolatency',
'-g',
String(FPS),
'-keyint_min',
String(FPS),
'-sc_threshold',
'0',
'-f',
'segment',
'-segment_time',
String(SEGMENT_SECONDS),
'-segment_format',
'mp4',
'-reset_timestamps',
'1',
'-strftime',
'1',
outPattern,
'-map',
'[vjpg]',
'-an',
'-q:v:1',
'8',
'-f',
'image2',
'-update',
'1',
outputSnapshot,
);
} else {
args.push(
'-r',
String(FPS),
'-vf',
`fps=${FPS},scale=${SCALE_WIDTH}:-1`,
'-an',
'-c:v',
'libx264',
'-preset',
'veryfast',
'-tune',
'zerolatency',
'-g',
String(FPS),
'-keyint_min',
String(FPS),
'-sc_threshold',
'0',
'-f',
'segment',
'-segment_time',
String(SEGMENT_SECONDS),
'-segment_format',
'mp4',
'-reset_timestamps',
'1',
'-strftime',
'1',
outPattern,
);
}
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
const stderrChunks = [];
let stderrSize = 0;
proc.stderr.on('data', (chunk) => {
if (!chunk || stderrSize > 8192) return;
stderrChunks.push(chunk);
stderrSize += chunk.length;
});
recorders.set(key, { proc, source });
proc.on('exit', (code, signal) => {
recorders.delete(key);
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
if (stderrBuffer && stderrBuffer.length) {
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
logger.warn('Replay recorder stderr', {
key,
stderrBytes: stderrBuffer.length,
stderrPreview: preview || '<non-utf8>',
});
} else {
logger.warn('Replay recorder stderr', { key, stderrBytes: 0 });
}
if (!shouldRecord(source)) {
return;
}
const delay = 2000;
logger.warn('Replay recorder exited; restarting', { key, code, signal });
setTimeout(() => {
if (!recorders.has(key) && shouldRecord(source)) {
spawnRecorder(source);
}
}, delay);
});
})
.catch((err) => {
logger.warn('Replay recorder setup failed', { key, err: err.message });
});
}
function stopRecorder(key) {
const entry = recorders.get(key);
if (!entry) return;
entry.proc.kill('SIGTERM');
recorders.delete(key);
}
async function removeSourceArtifacts(key, source) {
try {
await fsp.rm(path.join(SEGMENT_DIR, key), { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to clear replay segments', { key, error: err.message });
}
if (source?.type === 'rover' && source?.id) {
try {
await fsp.rm(path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`), { force: true });
} catch (err) {
logger.warn('Failed to clear rover snapshot artifact', { roverId: source.id, error: err.message });
}
}
}
function shouldRecord(source) {
if (source.type === 'room') {
return Boolean(source.streamUrl);
}
return roverManager.canReplayRoverId(source.id);
}
function syncRecorders() {
const sources = listSources();
const desiredKeys = new Set();
sources.forEach((source) => {
if (!shouldRecord(source)) return;
const key = sourceKey(source);
desiredKeys.add(key);
if (!recorders.has(key)) {
spawnRecorder(source);
}
});
Array.from(recorders.keys()).forEach((key) => {
if (!desiredKeys.has(key)) {
const entry = recorders.get(key);
stopRecorder(key);
removeSourceArtifacts(key, entry?.source).catch(() => {});
}
});
}
async function cleanupSegments() {
try {
await ensureDir(SEGMENT_DIR);
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
const entries = await fsp.readdir(SEGMENT_DIR, { withFileTypes: true });
let totalBytes = 0;
const files = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirPath = path.join(SEGMENT_DIR, entry.name);
const inner = await fsp.readdir(dirPath, { withFileTypes: true });
for (const file of inner) {
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
const filePath = path.join(dirPath, file.name);
const stats = await fsp.stat(filePath);
totalBytes += stats.size;
files.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size });
if (stats.mtimeMs < cutoff) {
await fsp.unlink(filePath);
}
}
const remaining = await fsp.readdir(dirPath);
if (!remaining.length) {
await fsp.rmdir(dirPath);
}
}
if (MAX_BYTES > 0 && totalBytes > MAX_BYTES) {
const overBy = totalBytes - MAX_BYTES;
let freed = 0;
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const file of files) {
if (freed >= overBy) break;
try {
await fsp.unlink(file.filePath);
freed += file.size;
} catch {
// ignore
}
}
}
} catch (err) {
logger.warn('Replay cleanup failed', err.message);
}
}
function start() {
syncRecorders();
cleanupSegments();
if (cleanupTimer) clearInterval(cleanupTimer);
cleanupTimer = setInterval(cleanupSegments, CLEANUP_INTERVAL_MS);
}
roomCameraEvents.on('update', () => {
syncRecorders();
});
roverManager.managerEvents.on('rover', () => {
syncRecorders();
});
roverManager.managerEvents.on('private', () => {
syncRecorders();
});
start();
module.exports = {
replaySegmentsDir: SEGMENT_DIR,
segmentSeconds: SEGMENT_SECONDS,
bufferSeconds: BUFFER_SECONDS,
};

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