Compare commits

...
250 Commits
Author SHA1 Message Date
legop3 b19bec7ee5 no persist admin status 2026-01-23 04:18:57 -05:00
legop3 987ef29f30 ban system 2026-01-23 04:12:38 -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
153 changed files with 11536 additions and 2871 deletions
+4
View File
@@ -11,4 +11,8 @@ config.h
robots.json robots.json
roverd-dummy roverd-dummy
server/config.yaml server/config.yaml
server/package-lock.json
package-lock.json package-lock.json
server/data/discord-guilds.json
server/data/community-goal.json
server/data
+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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+24 -21
View File
@@ -32,32 +32,35 @@ else
fi fi
run_pipeline() { run_pipeline() {
"${FFMPEG_BIN_PATH}" \ arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw \
-hide_banner \ | "${FFMPEG_BIN_PATH}" \
-loglevel warning \ -hide_banner \
-fflags nobuffer \ -loglevel warning \
-rtbufsize 0 \ -fflags nobuffer \
-thread_queue_size 4096 \ -rtbufsize 0 \
-f s32le \ -thread_queue_size 4096 \
-ar "${AUDIO_RATE}" \ -f s32le \
-ac "${AUDIO_CHANNELS}" \ -ar "${AUDIO_RATE}" \
-i <(arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw) \ -ac "${AUDIO_CHANNELS}" \
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=30dB" \ -i pipe:0 \
-c:a libopus \ -af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=25dB" \
-b:a 24000 \ -c:a libopus \
-ar:a 16000 \ -b:a 24000 \
-ac:a 1 \ -ar:a 16000 \
-application lowdelay \ -ac:a 1 \
-frame_duration 20 \ -application lowdelay \
-compression_level 0 \ -frame_duration 20 \
-f mpegts \ -compression_level 0 \
"${AUDIO_PUBLISH_URL}" -f mpegts \
"${AUDIO_PUBLISH_URL}"
} }
trap 'kill 0 2>/dev/null' EXIT INT TERM
while true; do while true; do
if run_pipeline; then if run_pipeline; then
exit 0 exit 0
fi fi
echo "Audio-only publisher exited, restarting in 2s..." >&2 echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
sleep 2 sleep 2
done done
+113 -49
View File
@@ -1,80 +1,144 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Disable history expansion so PUBLISH_URL values with "!" are safe when sourcing env files.
# Keep history expansion off so values containing "!" are safe.
set +H 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
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2 echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
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 the camera 180deg (supported by rpicam-vid/libcamera-vid)
FLIP_ARGS=(--rotation 180) 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"
elif command -v rpicam-vid >/dev/null 2>&1; then elif command -v rpicam-vid >/dev/null 2>&1; then
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)" LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
elif command -v libcamera-vid >/dev/null 2>&1; then elif command -v libcamera-vid >/dev/null 2>&1; then
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)" LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
else else
echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2 echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2
exit 1 exit 1
fi fi
if [[ -n "${FFMPEG_BIN:-}" ]]; then if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN" FFMPEG_BIN_PATH="$FFMPEG_BIN"
elif command -v ffmpeg >/dev/null 2>&1; then elif command -v ffmpeg >/dev/null 2>&1; then
FFMPEG_BIN_PATH="$(command -v ffmpeg)" FFMPEG_BIN_PATH="$(command -v ffmpeg)"
else else
echo "ffmpeg not found; install it via apt install ffmpeg." >&2 echo "ffmpeg not found; install it via apt install ffmpeg." >&2
exit 1 exit 1
fi fi
run_pipeline() { run_pipeline() {
"${LIBCAMERA_BIN_PATH}" \ "${LIBCAMERA_BIN_PATH}" \
--inline \ --inline \
--timeout 0 \ --timeout 0 \
--width "${VIDEO_WIDTH}" \ "${MODE_ARGS[@]}" \
--height "${VIDEO_HEIGHT}" \ --width "${VIDEO_WIDTH}" \
"${FLIP_ARGS[@]}" \ --height "${VIDEO_HEIGHT}" \
--framerate "${VIDEO_FPS}" \ "${FLIP_ARGS[@]}" \
--bitrate "${VIDEO_BITRATE}" \ --framerate "${VIDEO_FPS}" \
--codec h264 \ --bitrate "${VIDEO_BITRATE}" \
--profile baseline \ --codec h264 \
--denoise cdn_off \ --profile baseline \
--nopreview \ --denoise auto \
--awb custom \ --nopreview \
--awbgains 1.5,1.8 \ --metering centre \
--output - \ --ev 0.1 \
| "${FFMPEG_BIN_PATH}" \ --awb auto \
-hide_banner \ --saturation 0.6 \
-loglevel warning \ --brightness 0 \
-fflags nobuffer \ --output - \
-use_wallclock_as_timestamps 1 \ | "${FFMPEG_BIN_PATH}" \
-f h264 \ -hide_banner \
-i pipe:0 \ -loglevel warning \
-c:v copy \ -fflags nobuffer \
-an \ -use_wallclock_as_timestamps 1 \
-flush_packets 1 \ -f h264 \
-f mpegts \ -i pipe:0 \
"${PUBLISH_URL}" -c:v copy \
-an \
-flush_packets 1 \
-f mpegts \
"${PUBLISH_URL}"
} }
while true; do while true; do
if run_pipeline; then if run_pipeline; then
exit 0 exit 0
fi fi
echo "Video publisher exited, restarting in 2s..." >&2 echo "Video publisher exited, restarting in 2s..." >&2
sleep 2 sleep 2
done done
-3
View File
@@ -215,9 +215,6 @@ cat > /var/lib/roverd/video.env <<'ENV'
# Managed by roverd; placeholder values will be overwritten at runtime. # Managed by roverd; placeholder values will be overwritten at runtime.
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
VIDEO_WIDTH=1280
VIDEO_HEIGHT=720
VIDEO_FPS=30
VIDEO_BITRATE=2000000 VIDEO_BITRATE=2000000
AUDIO_ENABLE=0 AUDIO_ENABLE=0
AUDIO_DEVICE=hw:0,0 AUDIO_DEVICE=hw:0,0
+7 -11
View File
@@ -42,7 +42,7 @@ func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, er
rpio.Close() rpio.Close()
return nil, err return nil, err
} }
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle) 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 return servo, nil
} }
@@ -118,6 +118,9 @@ func (s *CameraServo) angleToPulse(angle float64) int {
} }
norm := (angle - s.cfg.MinAngle) / totalRange norm := (angle - s.cfg.MinAngle) / totalRange
norm = math.Max(0, math.Min(1, norm)) norm = math.Max(0, math.Min(1, norm))
if s.cfg.Invert {
norm = 1 - norm
}
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange))) return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
} }
@@ -129,15 +132,8 @@ func (s *CameraServo) pulseToAngle(pulse int) float64 {
} }
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange) norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
norm = math.Max(0, math.Min(1, norm)) 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) return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
} }
func clampInt(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}
+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
}
+10 -1
View File
@@ -69,10 +69,19 @@ func main() {
defer cameraServo.Close() 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, cameraServo, 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 {
+18
View File
@@ -8,6 +8,7 @@ type helloMessage struct {
Media MediaConfig `json:"media"` Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"` CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"` Audio AudioConfig `json:"audio"`
NightVision NightVisionConfig `json:"nightVision"`
} }
type sensorMessage struct { type sensorMessage struct {
@@ -26,6 +27,8 @@ type inboundMessage struct {
Media *mediaCommand `json:"media,omitempty"` Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"` Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"` TTS *ttsPayload `json:"tts,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
} }
type driveDirectPayload struct { type driveDirectPayload struct {
@@ -61,6 +64,21 @@ type ttsPayload struct {
Speak bool `json:"speak,omitempty"` Speak bool `json:"speak,omitempty"`
} }
type nightVisionPayload struct {
Action string `json:"action"`
}
type songPayload struct {
Slot *int `json:"slot,omitempty"`
Notes []songNote `json:"notes"`
Loop bool `json:"loop,omitempty"`
}
type songNote struct {
Note int `json:"note"`
Duration int `json:"duration"`
}
type ackMessage struct { type ackMessage struct {
Type string `json:"type"` Type string `json:"type"`
ID string `json:"id"` ID string `json:"id"`
+39 -13
View File
@@ -93,6 +93,14 @@ type CameraServoConfig struct {
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"` HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"` NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"` AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
Invert bool `yaml:"invert" json:"invert"`
}
type NightVisionConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
InitialOn bool `yaml:"initialOn" json:"initialOn"`
} }
type Config struct { type Config struct {
@@ -105,6 +113,7 @@ type Config struct {
Media MediaConfig `yaml:"media"` Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"` CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"` Audio AudioConfig `yaml:"audio"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
@@ -127,9 +136,6 @@ 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,
VideoHeight: 720,
VideoFPS: 30,
VideoBitrate: 2000000, VideoBitrate: 2000000,
}, },
CameraServo: CameraServoConfig{ CameraServo: CameraServoConfig{
@@ -154,6 +160,12 @@ func LoadConfig(path string) (*Config, error) {
DefaultVoice: "rms", DefaultVoice: "rms",
DefaultPitch: 50, DefaultPitch: 50,
}, },
NightVision: NightVisionConfig{
Enabled: true,
GPIOPin: 22,
GPIOChip: "gpiochip0",
InitialOn: true,
},
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
@@ -182,15 +194,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
} }
@@ -214,6 +217,9 @@ func LoadConfig(path string) (*Config, error) {
if err := validateServoConfig(&cfg.CameraServo); err != nil { if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err) return nil, fmt.Errorf("cameraServo: %w", err)
} }
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
return nil, fmt.Errorf("nightVision: %w", err)
}
validateAudioConfig(&cfg.Audio) validateAudioConfig(&cfg.Audio)
return &cfg, nil return &cfg, nil
} }
@@ -231,9 +237,16 @@ func validateServoConfig(cfg *CameraServoConfig) error {
if cfg.CycleLen <= 0 { if cfg.CycleLen <= 0 {
return errors.New("cycleLen must be > 0") return errors.New("cycleLen must be > 0")
} }
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 || cfg.MinPulseUs >= cfg.MaxPulseUs { if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 {
return errors.New("minPulseUs/maxPulseUs invalid") 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 { if cfg.MinAngle >= cfg.MaxAngle {
return errors.New("minAngle must be less than maxAngle") return errors.New("minAngle must be less than maxAngle")
} }
@@ -278,6 +291,19 @@ func validateAudioConfig(cfg *AudioConfig) {
} }
} }
func validateNightVisionConfig(cfg *NightVisionConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.GPIOPin <= 0 {
return errors.New("gpioPin must be > 0")
}
if cfg.GPIOChip == "" {
cfg.GPIOChip = "gpiochip0"
}
return nil
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) { func derivePublishURL(serverURL, streamName string, port int) (string, error) {
if streamName == "" { if streamName == "" {
return "", errors.New("missing stream name for publishUrl") return "", errors.New("missing stream name for publishUrl")
+10 -4
View File
@@ -16,7 +16,7 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.AudioPublishURL == "" && audio.CaptureEnabled { if media.AudioPublishURL == "" && audio.CaptureEnabled {
return fmt.Errorf("audio publishUrl missing") return fmt.Errorf("audio publishUrl missing")
} }
if media.VideoWidth <= 0 || media.VideoHeight <= 0 || media.VideoFPS <= 0 || media.VideoBitrate <= 0 { if media.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 {
@@ -27,9 +27,15 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if audio.CaptureEnabled && media.AudioPublishURL != "" { if audio.CaptureEnabled && media.AudioPublishURL != "" {
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL) fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
} }
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth) if media.VideoWidth > 0 {
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight) fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS) }
if media.VideoHeight > 0 {
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
}
if media.VideoFPS > 0 {
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
}
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate) fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
audioDevice := audio.CaptureDevice audioDevice := audio.CaptureDevice
if audioDevice == "" || audioDevice == "rovermic" { if audioDevice == "" || audioDevice == "rovermic" {
+97
View File
@@ -0,0 +1,97 @@
//go:build !dummy
package roverd
import (
"fmt"
"log"
"strings"
"sync"
gpiocdev "github.com/warthog618/go-gpiocdev"
)
type NightVisionLight struct {
cfg NightVisionConfig
logger *log.Logger
line *gpiocdev.Line
mu sync.Mutex
on bool
closed bool
}
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("night vision disabled")
}
chip := cfg.GPIOChip
if chip == "" {
chip = "gpiochip0"
}
initial := 0
if cfg.InitialOn {
initial = 1
}
line, err := gpiocdev.RequestLine(
chip,
cfg.GPIOPin,
gpiocdev.AsOutput(initial),
gpiocdev.WithConsumer("roverd-nightvision"),
)
if err != nil {
return nil, fmt.Errorf("gpio request: %w", err)
}
nv := &NightVisionLight{
cfg: cfg,
logger: logger,
line: line,
on: cfg.InitialOn,
}
logger.Printf("night vision LED on GPIO %d (initial=%v)", cfg.GPIOPin, cfg.InitialOn)
return nv, nil
}
func (n *NightVisionLight) Close() {
n.mu.Lock()
defer n.mu.Unlock()
if n.closed {
return
}
_ = n.line.SetValue(boolToGPIO(n.on))
n.line.Close()
n.closed = true
}
func (n *NightVisionLight) HandleAction(action string) error {
n.mu.Lock()
defer n.mu.Unlock()
if n.closed {
return fmt.Errorf("night vision controller closed")
}
act := strings.ToLower(strings.TrimSpace(action))
switch act {
case "", "toggle":
return n.setLocked(!n.on)
case "on":
return n.setLocked(true)
case "off":
return n.setLocked(false)
default:
return fmt.Errorf("unknown action %q", action)
}
}
func (n *NightVisionLight) setLocked(on bool) error {
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
return err
}
n.on = on
return nil
}
func boolToGPIO(value bool) int {
if value {
return 1
}
return 0
}
+20
View File
@@ -0,0 +1,20 @@
//go:build dummy
package roverd
import (
"fmt"
"log"
)
type NightVisionLight struct{}
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
return nil, fmt.Errorf("night vision not supported in dummy build")
}
func (n *NightVisionLight) Close() {}
func (n *NightVisionLight) HandleAction(action string) error {
return fmt.Errorf("night vision not supported in dummy build")
}
+6 -3
View File
@@ -17,9 +17,6 @@ maxWheelSpeed: 350
media: media:
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
publishPort: 9000 publishPort: 9000
videoWidth: 1280
videoHeight: 720
videoFps: 30
videoBitrate: 2000000 videoBitrate: 2000000
manage: true manage: true
service: video-publisher.service service: video-publisher.service
@@ -32,6 +29,7 @@ cameraServo:
cycleLen: 20000 cycleLen: 20000
minPulseUs: 900 minPulseUs: 900
maxPulseUs: 2100 maxPulseUs: 2100
invert: false
minAngle: -15 minAngle: -15
maxAngle: 30 maxAngle: 30
homeAngle: 0 homeAngle: 0
@@ -47,3 +45,8 @@ audio:
defaultEngine: flite defaultEngine: flite
defaultVoice: rms defaultVoice: rms
defaultPitch: 50 defaultPitch: 50
nightVision:
enabled: true
gpioPin: 22
gpioChip: gpiochip0
initialOn: true
+1 -1
View File
@@ -14,7 +14,7 @@ import (
const ( const (
sensorHeader = 19 sensorHeader = 19
sensorReadTimeout = 150 * time.Millisecond sensorReadTimeout = 150 * time.Millisecond
sensorThrottleMinimum = 250 * time.Millisecond sensorThrottleMinimum = 50 * time.Millisecond
) )
type SensorStreamer struct { type SensorStreamer struct {
+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
}
+4 -4
View File
@@ -8,7 +8,7 @@ import (
"time" "time"
) )
func (c *WSClient) handleTTSPayload(payload *ttsPayload) error { func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) error {
if payload == nil { if payload == nil {
return fmt.Errorf("tts payload required") return fmt.Errorf("tts payload required")
} }
@@ -44,7 +44,7 @@ func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
} }
pitch = clampInt(pitch, 0, 99) pitch = clampInt(pitch, 0, 99)
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel() defer cancel()
var cmd *exec.Cmd var cmd *exec.Cmd
@@ -55,14 +55,14 @@ func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
args = append(args, "-p", fmt.Sprintf("%d", pitch)) args = append(args, "-p", fmt.Sprintf("%d", pitch))
} }
args = append(args, text) args = append(args, text)
cmd = exec.CommandContext(ctx, "espeak", args...) cmd = exec.CommandContext(runCtx, "espeak", args...)
case "flite", "f": case "flite", "f":
args := []string{} args := []string{}
if voice != "" { if voice != "" {
args = append(args, "-voice", voice) args = append(args, "-voice", voice)
} }
args = append(args, "-t", text) args = append(args, "-t", text)
cmd = exec.CommandContext(ctx, "flite", args...) cmd = exec.CommandContext(runCtx, "flite", args...)
default: default:
return fmt.Errorf("unsupported tts engine: %s", engine) return fmt.Errorf("unsupported tts engine: %s", engine)
} }
+204 -9
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"sync"
"time" "time"
"nhooyr.io/websocket" "nhooyr.io/websocket"
@@ -15,13 +16,25 @@ 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 servo *CameraServo
nightVision *NightVisionLight
log *log.Logger log *log.Logger
recoverMu sync.Mutex
recovering bool
ttsQueue chan *ttsPayload
connMu sync.Mutex
connected bool
disconnectT *time.Timer
seekIssued bool
} }
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, servo *CameraServo, logger *log.Logger) *WSClient { func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
}
return &WSClient{ return &WSClient{
cfg: cfg, cfg: cfg,
adapter: adapter, adapter: adapter,
@@ -29,16 +42,21 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
events: events, events: events,
media: media, media: media,
servo: servo, servo: servo,
nightVision: nightVision,
log: logger, log: logger,
ttsQueue: ttsQueue,
} }
} }
func (c *WSClient) Run(ctx context.Context) error { func (c *WSClient) Run(ctx context.Context) error {
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil) conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
if err != nil { if err != nil {
c.markDisconnected()
return err return err
} }
c.markConnected()
defer conn.Close(websocket.StatusInternalError, "closed") defer conn.Close(websocket.StatusInternalError, "closed")
defer c.markDisconnected()
if err := c.sendHello(ctx, conn); err != nil { if err := c.sendHello(ctx, conn); err != nil {
return err return err
@@ -48,6 +66,7 @@ func (c *WSClient) Run(ctx context.Context) error {
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
c.startTTSWorker(ctx)
go func() { go func() {
errCh <- c.readLoop(ctx, conn) errCh <- c.readLoop(ctx, conn)
}() }()
@@ -72,6 +91,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Media: c.cfg.Media, Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo, CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio, Audio: c.cfg.Audio,
NightVision: c.cfg.NightVision,
} }
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin) 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)
@@ -123,12 +143,9 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.adapter.MotorPWM(main, side, vac) return c.adapter.MotorPWM(main, side, vac)
case msg.SensorStream != nil: case msg.SensorStream != nil:
if msg.SensorStream.Enable { if msg.SensorStream.Enable {
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { return c.adapter.StartSensorStream(defaultStreamPackets)
return err
}
return c.adapter.PauseSensorStream(false)
} }
return c.adapter.PauseSensorStream(true) return nil
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 {
@@ -152,12 +169,57 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
} }
return c.handleServoCommand(msg.Servo) return c.handleServoCommand(msg.Servo)
case msg.TTS != nil: case msg.TTS != nil:
return c.handleTTSPayload(msg.TTS) return c.enqueueTTS(msg.TTS)
case msg.NightVision != nil:
if c.nightVision == nil {
return fmt.Errorf("night vision disabled")
}
return c.nightVision.HandleAction(msg.NightVision.Action)
case msg.Song != nil:
slot := 0
if msg.Song.Slot != nil {
slot = clampInt(*msg.Song.Slot, 0, 4)
}
return c.adapter.PlaySong(slot, msg.Song.Notes)
default: default:
return fmt.Errorf("unsupported command type: %s", msg.Type) return fmt.Errorf("unsupported command type: %s", msg.Type)
} }
} }
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
if c.ttsQueue == nil {
return fmt.Errorf("tts disabled")
}
select {
case c.ttsQueue <- payload:
return nil
default:
return fmt.Errorf("tts busy")
}
}
func (c *WSClient) startTTSWorker(ctx context.Context) {
if c.ttsQueue == nil {
return
}
go func() {
for {
select {
case <-ctx.Done():
return
case payload := <-c.ttsQueue:
if payload == nil {
continue
}
if err := c.handleTTSPayload(ctx, payload); err != nil {
c.log.Printf("tts failed: %v", err)
c.emitEvent("tts.error", map[string]any{"error": err.Error()})
}
}
}
}()
}
func (c *WSClient) handleServoCommand(payload *servoPayload) error { func (c *WSClient) handleServoCommand(payload *servoPayload) error {
switch { switch {
case payload.Angle != nil: case payload.Angle != nil:
@@ -172,11 +234,50 @@ func (c *WSClient) handleServoCommand(payload *servoPayload) error {
} }
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) { 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 { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return 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(),
@@ -210,6 +311,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
} }
} }
func (c *WSClient) emitEvent(event string, data map[string]any) {
if c.events == nil {
return
}
select {
case c.events <- RoverEvent{
Type: "event",
Event: event,
Ts: time.Now().UnixMilli(),
Data: data,
}:
default:
}
}
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error { func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
data, err := json.Marshal(v) data, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -232,7 +348,86 @@ func (c *WSClient) ensureSensorStream() error {
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
return err return err
} }
return c.adapter.PauseSensorStream(false) return nil
}
const disconnectSeekDelay = time.Minute
func (c *WSClient) markConnected() {
c.connMu.Lock()
c.connected = true
c.seekIssued = false
if c.disconnectT != nil {
c.disconnectT.Stop()
c.disconnectT = nil
}
c.connMu.Unlock()
}
func (c *WSClient) markDisconnected() {
c.connMu.Lock()
if c.connected {
c.connected = false
}
if c.disconnectT == nil {
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
}
c.connMu.Unlock()
}
func (c *WSClient) handleDisconnectTimeout() {
c.connMu.Lock()
if c.connected || c.seekIssued {
c.connMu.Unlock()
return
}
c.seekIssued = true
c.connMu.Unlock()
if err := c.adapter.SeekDock(); err != nil {
c.log.Printf("seek dock on disconnect failed: %v", err)
return
}
c.log.Printf("seek dock issued after websocket disconnect")
}
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
c.recoverMu.Lock()
if c.recovering {
c.recoverMu.Unlock()
return
}
c.recovering = true
c.recoverMu.Unlock()
defer func() {
c.recoverMu.Lock()
c.recovering = false
c.recoverMu.Unlock()
}()
c.emitEvent("sensorWatchdog.restart", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
if err := c.adapter.StartOI(); err != nil {
c.log.Printf("watchdog start OI failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
return
}
if cmdPause > 0 {
time.Sleep(cmdPause)
}
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
c.log.Printf("watchdog start stream failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
return
}
c.emitEvent("sensorWatchdog.ok", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
} }
func isModeOpcode(op byte) bool { func isModeOpcode(op byte) bool {
+2
View File
@@ -9,6 +9,8 @@ User=roverd
Group=roverd Group=roverd
EnvironmentFile=/var/lib/roverd/video.env EnvironmentFile=/var/lib/roverd/video.env
ExecStart=/usr/local/bin/audio-only-publisher ExecStart=/usr/local/bin/audio-only-publisher
KillMode=control-group
TimeoutStopSec=5
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
+13
View File
@@ -0,0 +1,13 @@
# general idea
- The idea of this feature is to toggle night vision on the camera by turning on an LED from GPIO on the pi which will me mounted in front of the camera's light sensor
- with this, the camera will disable night vision when the LED is ON, and enable night vision when the LED is OFF.
- this will only really involve pi and webui programming. the server passes commands straight through.
- do not track the state of night vision, it is not needed.
## pi side
- new GPIO on/off control on GPIO 17, 27, or 22. any of these will work.
## web UI
- a new keyboard shortcut to toggle night vision
- a new button in the mobile UI to toggle night vision
- put it above the mobile horizontal servo slider
+6
View File
@@ -0,0 +1,6 @@
## general idea
- new page
- designed for a very small screen
- not interactive at all
- a small summarized status of what's going on in the basement (where the rovers are)
- this small screen does not have space to show all info and all video for all rovers
+21
View File
@@ -0,0 +1,21 @@
# general idea
- a help system that pops up onscreen
- changes based on control layout
- mobile / desktop
- on desktop especially, keybinds will dynamically update, based on what is actually assigned
# UI layout specifics:
- remake the current help component from scratch
- leave it in the place where it is, so people can always look at the help
- match the styling of the rest of the page
- make sure the help is easy to edit
- add a pop-up which displays underneath any other popup layers, which will display the help component in a large format
- make this pop-up show every time UNLESS the user checks "dont show again"
- use the settings system to store this in the browser
# what will the help explain?
- how to use the nickname and chat
- how to start driving the rover
- rover controls
- this is the biggest thing that will change in different layouts
- how it works technically (just a placeholder for now)
+7 -7
View File
@@ -1,14 +1,11 @@
## raspberry pi ## raspberry pi
- need to throttle sensor sending, maybe only send one in every 5 packets. <!-- - need to throttle sensor sending, maybe only send one in every 5 packets. -->
## 3d models for camera: ## 3d models for camera:
- https://www.thingiverse.com/thing:2873677 - https://www.thingiverse.com/thing:2873677
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit - https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
- https://www.thingiverse.com/thing:4514531 - https://www.thingiverse.com/thing:4514531
## spectator page
- completely remake it from scratch
## todo ## todo
<!-- 1. battery manager --> <!-- 1. battery manager -->
<!-- 2. pi-side sensor throttle (1/5th) --> <!-- 2. pi-side sensor throttle (1/5th) -->
@@ -22,6 +19,9 @@
<!-- 9. online user list --> <!-- 9. online user list -->
<!-- 10. chat --> <!-- 10. chat -->
<!-- 9. discord invite button --> <!-- 9. discord invite button -->
10. redo both mobile layouts <!-- 10. redo both mobile layouts -->
11. redo spectator view (last) <!-- 11. redo spectator view (last) -->
21. finally.. set the favicon and title <!-- 21. finally.. set the favicon and title -->
22. rover snapshots freezing and never coming back
23. rover snapshot -> video switching needs to be smoother, no black flash. connect and play before showing.
24. rover snapshots delayed (not just because of framerate)
+14
View File
@@ -0,0 +1,14 @@
# general idea
- users can choose from preset, and create UI themes
- the default theme is the current in place theme
- themes will NOT change margins, borders, padding, spacing, etc.
- should be as simple as changing the global css stuff
- themes can change colors, fonts, border radius, borders, etc
## technical stuff
- save theme info in
## UI for themeing
- allow people to tune every theme element that there is to tune
- allow people to export themes as json files, and import them
-
+140 -23
View File
@@ -2,41 +2,158 @@
set -euo pipefail set -euo pipefail
# Configurable via environment # Configurable via environment
DEVICE="${DEVICE:-/dev/video0}" export DEVICE="${DEVICE:-/dev/video0}"
RESOLUTION="${RESOLUTION:-640x480}" export RESOLUTION="${RESOLUTION:-640x480}"
QUALITY="${QUALITY:-5}" # ffmpeg MJPEG quality (lower is better) export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
PORT="${PORT:-8088}" export PORT="${PORT:-8088}"
WORKDIR="${WORKDIR:-/run/roomcam}" export WORKDIR="${WORKDIR:-/run/roomcam}"
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG. # Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}" export INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
export MJPEG_FPS="${MJPEG_FPS:-15}"
export MJPEG_QUALITY="${MJPEG_QUALITY:-8}"
mkdir -p "${WORKDIR}" mkdir -p "${WORKDIR}"
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg" SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
rm -f "${SNAPSHOT_PATH}" rm -f "${SNAPSHOT_PATH}"
# push
cleanup() { cleanup() {
[[ -n "${FFMPEG_PID:-}" ]] && kill "${FFMPEG_PID}" 2>/dev/null || true
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true [[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
} }
trap cleanup EXIT trap cleanup EXIT
trap 'exit 0' SIGTERM INT trap 'exit 0' SIGTERM INT
FFMPEG_INPUT_ARGS=(-f v4l2 -input_format "${INPUT_FORMAT}" -video_size "${RESOLUTION}" -i "${DEVICE}") cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
FFMPEG_FILTERS=() import os
if [[ "${INPUT_FORMAT}" == bayer_* ]]; then import threading
# Convert raw Bayer to a JPEG-friendly pixel format. import time
FFMPEG_FILTERS=(-pix_fmt yuv420p) import subprocess
fi from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
/usr/bin/ffmpeg -y \ DEVICE = os.environ.get("DEVICE", "/dev/video0")
-loglevel warning -nostats \ RESOLUTION = os.environ.get("RESOLUTION", "640x480")
"${FFMPEG_INPUT_ARGS[@]}" \ INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
"${FFMPEG_FILTERS[@]}" \ MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
-q:v "${QUALITY}" \ MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
-f image2 -update 1 "${SNAPSHOT_PATH}" & WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
FFMPEG_PID=$! SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
/usr/bin/python3 -u -m http.server "${PORT}" --directory "${WORKDIR}" --bind 0.0.0.0 & 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=$! HTTP_PID=$!
wait -n "${FFMPEG_PID}" "${HTTP_PID}" wait -n "${HTTP_PID}"
+1 -2
View File
@@ -11,8 +11,7 @@ Environment=QUALITY=5
Environment=PORT=8088 Environment=PORT=8088
Environment=WORKDIR=/run/roomcam Environment=WORKDIR=/run/roomcam
Environment=INPUT_FORMAT=mjpeg Environment=INPUT_FORMAT=mjpeg
WorkingDirectory=/home/daniel/gits/MultiRoombaRover/roomcam-service ExecStart=/usr/bin/env bash /usr/local/bin/room-cam-snapshot.sh
ExecStart=/usr/bin/env bash /home/daniel/gits/MultiRoombaRover/roomcam-service/room-cam-snapshot.sh
Restart=always Restart=always
RestartSec=2 RestartSec=2
User=root User=root
+6 -1
View File
@@ -7,6 +7,7 @@ admins:
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
discord_id: "0987654321" discord_id: "0987654321"
lockdown: true lockdown: true
timezone: "America/New_York"
media: media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request # Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep # http://<base>/<roverId>/whep
@@ -27,18 +28,22 @@ roomCameras:
name: "Lobby Camera" name: "Lobby Camera"
description: "Wide shot of the staging area." description: "Wide shot of the staging area."
url: "http://192.168.0.50/snapshot.jpg" url: "http://192.168.0.50/snapshot.jpg"
streamUrl: "http://192.168.0.50/stream.mjpg"
- id: "workshop" - id: "workshop"
name: "Workshop Bench" name: "Workshop Bench"
description: "Shows the workbench and charging docks." description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg" url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
discord: discord:
token: "DISCORD_BOT_TOKEN" token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to guildId: "123456789012345678" # optional; bot works in any guild it's invited to
siteUrl: "https://rover.example.com"
channels: channels:
announcements: "123456789012345678" announcements: "123456789012345678"
adminAlerts: "123456789012345678" adminAlerts: "123456789012345678"
chatBridge: "123456789012345678" # chat bridge is configured per guild via `rs bridge` commands
replay: "123456789012345678"
roles: roles:
announcementPing: "123456789012345678" announcementPing: "123456789012345678"
adminPing: "123456789012345678" adminPing: "123456789012345678"
+7
View File
@@ -17,13 +17,20 @@ require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/nicknameService'); require('./src/services/nicknameService');
require('./src/services/chatService'); require('./src/services/chatService');
require('./src/services/communityGoalService');
require('./src/services/videoSessions'); require('./src/services/videoSessions');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
require('./src/services/roomCameraSocketService'); require('./src/services/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/homeAssistantService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/moderationService');
require('./src/services/batteryManager'); require('./src/services/batteryManager');
require('./src/services/replaySocketService');
require('./src/services/replaySegmentManager');
require('./src/services/discordBotService'); 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
-1885
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,8 @@
"home-assistant-js-websocket": "^3.1.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",
"sharp": "^0.33.5",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CnvHZcbU.js"></script> <script type="module" crossorigin src="/assets/index-DECQ2TrX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css"> <link rel="stylesheet" crossorigin href="/assets/index-f8xLbmgU.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+16 -4
View File
@@ -1,16 +1,28 @@
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');
const { v4: uuidv4 } = require('uuid');
const config = require('./config'); const config = require('./config');
const { parseCookieHeader } = require('../helpers/cookieParser');
const VISITOR_COOKIE = 'roverd_visitor';
const VISITOR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
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((req, res, next) => {
app.get('/spectate', (req, res) => { const cookies = parseCookieHeader(req.headers?.cookie || '');
res.sendFile(path.join(config.staticDir, 'index.html')); let token = cookies[VISITOR_COOKIE];
if (!token) {
token = uuidv4();
const cookie = `${VISITOR_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${VISITOR_COOKIE_MAX_AGE}`;
res.setHeader('Set-Cookie', cookie);
}
req.visitorToken = token;
next();
}); });
app.use(express.static(config.staticDir, { index: false }));
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
+3
View File
@@ -3,6 +3,9 @@ const { httpServer } = require('./http');
const io = new SocketIOServer(httpServer, { const io = new SocketIOServer(httpServer, {
cors: { origin: '*' }, cors: { origin: '*' },
transports: ['websocket', 'polling'],
pingInterval: 5000,
pingTimeout: 7000,
}); });
// Allow more service listeners without warnings. // Allow more service listeners without warnings.
-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();
} }
}); });
+17
View File
@@ -0,0 +1,17 @@
function parseCookieHeader(header = '') {
if (!header || typeof header !== 'string') return {};
return header
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.reduce((acc, part) => {
const [key, ...rest] = part.split('=');
if (!key) return acc;
acc[key] = rest.join('=');
return acc;
}, {});
}
module.exports = {
parseCookieHeader,
};
+45
View File
@@ -0,0 +1,45 @@
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 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,
};
+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,
};
+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,
}; };
+45 -1
View File
@@ -54,6 +54,15 @@ roverManager.managerEvents.on('rover', ({ action }) => {
} }
}); });
roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
if (!socketId || !roverId) return;
const socket = socketRefs.get(socketId);
if (!socket) return;
assignments.set(socketId, roverId);
waiting.delete(socketId);
assignmentEvents.emit('update', socketId);
});
function assignSocket(socket) { function assignSocket(socket) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') { if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
return; return;
@@ -156,7 +165,31 @@ function pickRover() {
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];
} }
@@ -177,4 +210,15 @@ module.exports = {
assignmentEvents, assignmentEvents,
describeAssignment, describeAssignment,
forceRelease, 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);
},
}; };
+14 -1
View File
@@ -147,7 +147,8 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
}); });
} }
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.5; const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.01;
// const waitingHalfOrMore = true;
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore; const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
if (shouldUnlock) { if (shouldUnlock) {
@@ -172,4 +173,16 @@ function handleSensorEvent({ roverId, sensors, 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('sensor', handleSensorEvent);
managerEvents.on('lock', handleLockEvent);
+205 -10
View File
@@ -1,4 +1,5 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
const io = require('../globals/io'); const io = require('../globals/io');
const logger = require('../globals/logger').child('chatService'); const logger = require('../globals/logger').child('chatService');
const { publishEvent, subscribe } = require('./eventBus'); const { publishEvent, subscribe } = require('./eventBus');
@@ -7,14 +8,36 @@ const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService'); const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService'); const { issueCommand } = require('./commandService');
const { isBannedSocket } = require('./moderationService');
const RATE_LIMIT_WINDOW_MS = 8000; const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5; const RATE_LIMIT_MAX = 5;
const rateBuckets = new Map(); // socketId -> [timestamps] const rateBuckets = new Map(); // socketId -> [timestamps]
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick']; 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 DUPLICATE_WINDOW_MS = 15000;
const lastMessageBySocket = new Map(); // socketId -> { text, ts } 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;
function withinRateLimit(socketId) { function withinRateLimit(socketId) {
const now = Date.now(); const now = Date.now();
@@ -26,8 +49,8 @@ function withinRateLimit(socketId) {
} }
function hasProfanity(text) { function hasProfanity(text) {
const lower = text.toLowerCase(); if (typeof text !== 'string' || !text) return false;
return PROFANITY_LIST.some((word) => lower.includes(word)); return profanityMatcher.hasMatch(text);
} }
function isDuplicate(socketId, text) { function isDuplicate(socketId, text) {
@@ -71,15 +94,95 @@ function buildMessage(socket, text, meta = {}) {
role: meta.role || getRole(socket), role: meta.role || getRole(socket),
roverId, roverId,
fromDiscord: Boolean(meta.fromDiscord), fromDiscord: Boolean(meta.fromDiscord),
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
text, text,
tts: meta.tts || null, tts: meta.tts || null,
}; };
} }
function buildTypingPayload(socket, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
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,
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 broadcastMessage(message) { function broadcastMessage(message) {
pushHistory(message);
publishEvent({ source: 'chat', type: 'chat:message', payload: 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 = {}) { function normalizeTtsOptions(raw = {}) {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') return null;
const speak = raw.speak !== false; const speak = raw.speak !== false;
@@ -109,7 +212,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
cb({ error: 'Slow down' }); cb({ error: 'Slow down' });
return; return;
} }
if (clean.length > 256) { if (clean.length > 150) {
cb({ error: 'Message too long' }); cb({ error: 'Message too long' });
return; return;
} }
@@ -121,14 +224,15 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
// cb({ error: 'Duplicate message' }); // cb({ error: 'Duplicate message' });
// return; // return;
// } // }
if (isKeymash(clean)) { // if (isKeymash(clean)) {
cb({ error: 'Message looks like spam' }); // cb({ error: 'Message looks like spam' });
return; // return;
} // }
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts); const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions }); const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
logger.info('Chat message', { socket: socket.id, roverId: message.roverId }); logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
broadcastMessage(message); broadcastMessage(message);
maybeSpeak(socket, message, ttsOptions); maybeSpeak(socket, message, ttsOptions);
cb({ success: true }); cb({ success: true });
@@ -158,7 +262,19 @@ function maybeSpeak(socket, message, ttsOptions) {
} }
} }
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null }) { 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 normalized = normalizeUserText(text);
const clean = normalized.trim(); const clean = normalized.trim();
if (!clean || clean.length > 256) { if (!clean || clean.length > 256) {
@@ -175,22 +291,101 @@ function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', rover
role, role,
roverId, roverId,
fromDiscord: true, fromDiscord: true,
discordGuildId,
discordGuildName,
discordGuildIconUrl,
discordChannelId,
discordUserId,
discordUserName,
discordUserAvatarUrl,
}); });
logger.info('External chat message', { roverId, nickname }); logger.info('External chat message', { roverId, nickname });
broadcastMessage(message); broadcastMessage(message);
return 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,
}) {
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) => { io.on('connection', (socket) => {
if (!isBannedSocket(socket)) {
socket.emit('chat:init', history);
}
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb)); 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);
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);
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
broadcastTyping(typingPayload);
});
}); });
subscribe('chat:message', ({ payload }) => { subscribe('chat:message', ({ payload }) => {
if (!payload) return; if (!payload) return;
io.emit('chat:message', payload); io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('chat:message', payload);
}
});
});
subscribe('chat:typing', ({ payload }) => {
if (!payload) return;
io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('chat:typing', payload);
}
});
}); });
module.exports = { module.exports = {
handleIncoming, handleIncoming,
sendExternalMessage, sendExternalMessage,
sendExternalTyping,
buildTypingPayload,
}; };
+64 -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 } = 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,10 +64,34 @@ io.on('connection', (socket) => {
if (!roverId) { if (!roverId) {
throw new Error('roverId required'); throw new Error('roverId required');
} }
if (!roverManager.canDrive(roverId, socket)) { const payload = data ? { ...data } : {};
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
if (!isSongCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control'); throw new Error('Not your turn or no control');
} }
const payload = data ? { ...data } : {}; const isAdminSocket = isAdmin(socket);
const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect && !isAdminSocket) {
const left = Number(driveDirect.left);
const right = Number(driveDirect.right);
const speed = Math.max(Math.abs(left), Math.abs(right));
const blockedUntil = driveCooldowns.get(roverId);
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
throw new Error('Drive blocked: dock protection cooldown');
}
if (speed > 0) {
let direction = 'turn';
if (left > 0 && right > 0) direction = 'forward';
if (left < 0 && right < 0) direction = 'backward';
lastDriveActivity.set(roverId, {
ts: Date.now(),
socketId: socket.id,
direction,
speed,
isAdmin: isAdminSocket,
});
}
}
const id = issueCommand(roverId, { type, ...payload }); const id = issueCommand(roverId, { type, ...payload });
logger.info('Queued command', socket.id, roverId, type); logger.info('Queued command', socket.id, roverId, type);
try { try {
@@ -65,3 +110,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');
}
});
+265
View File
@@ -0,0 +1,265 @@
const path = require('path');
const fsp = require('fs/promises');
const sharp = require('sharp');
const { getMode } = require('./modeManager');
const roverManager = require('./roverManager');
const { getActiveDrivers, getTurnQueues } = require('./turnService');
const { getRoomCameras } = require('./roomCameraService');
const { getRoomCameraState } = require('./roomCameraSnapshotService');
const { loadConfig } = require('../helpers/configLoader');
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', 'public', 'index.html');
const BITMAP_PATH = path.join(__dirname, '..', '..', 'public', 'bitmap.png');
const OG_WIDTH = 1200;
const OG_HEIGHT = 630;
const BASE_BG = { r: 8, g: 12, b: 22 };
let cachedIndexHtml = null;
let cachedIndexMtimeMs = 0;
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&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 buildEmbedCopy(state, camera) {
const roversOnline = state?.rovers?.length || 0;
const driverCount = Object.keys(state?.activeDrivers || {}).length;
const mode = state?.mode || 'open';
const modeLabel = {
open: 'open drive',
turns: 'turns mode',
admin: 'admin mode',
lockdown: 'locked',
}[mode] || mode;
let title = 'Multi Roomba Rover';
if (mode === 'lockdown') {
title = 'Private mode is on';
} else if (roversOnline === 0) {
title = 'Rovers offline - check back soon';
} else if (driverCount > 0) {
title = 'Rovers in use - drive a rover';
} else if (mode === 'turns') {
title = 'Controls open - jump in';
} else {
title = 'Controls open - drive a rover';
}
const descriptionParts = [];
descriptionParts.push(`${roversOnline} rover${roversOnline === 1 ? '' : 's'} online`);
if (driverCount > 0) {
descriptionParts.push(`${driverCount} driving`);
} else {
descriptionParts.push('no active drivers');
}
if (mode === 'lockdown') {
descriptionParts.push('privacy mode');
} else {
descriptionParts.push(modeLabel);
}
const description = descriptionParts.join(' | ');
const statsParts = [
`${roversOnline} online`,
driverCount > 0 ? `${driverCount} driving` : 'no drivers',
];
if (mode === 'lockdown') {
statsParts.push('privacy mode');
} else {
statsParts.push(modeLabel);
}
const cameraLabel = camera?.name || camera?.id || 'room cam';
return {
title,
description,
subtitle: 'Control a live rover from your browser',
stats: statsParts.join(' | '),
cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`,
};
}
function buildMetaTags({ title, description, imageUrl, pageUrl }) {
const safeTitle = escapeHtml(title);
const safeDescription = escapeHtml(description);
const safeImage = escapeHtml(imageUrl);
const safeUrl = escapeHtml(pageUrl);
return [
'<!-- embed meta -->',
`<meta name="description" content="${safeDescription}" />`,
`<meta property="og:title" content="${safeTitle}" />`,
`<meta property="og:description" content="${safeDescription}" />`,
'<meta property="og:type" content="website" />',
`<meta property="og:url" content="${safeUrl}" />`,
`<meta property="og:image" content="${safeImage}" />`,
'<meta property="og:image:width" content="1200" />',
'<meta property="og:image:height" content="630" />',
`<meta property="og:site_name" content="${safeTitle}" />`,
'<meta name="twitter:card" content="summary_large_image" />',
`<meta name="twitter:title" content="${safeTitle}" />`,
`<meta name="twitter:description" content="${safeDescription}" />`,
`<meta name="twitter:image" content="${safeImage}" />`,
'<!-- /embed meta -->',
].join('\n ');
}
async function renderIndexHtml(req) {
const baseUrl = getBaseUrl(req);
const state = {
mode: getMode(),
rovers: roverManager.getRoster(),
activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(),
};
const config = loadConfig();
const pageTitle = config?.site?.title || 'Roomba Rover';
const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera);
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`;
const metaBlock = buildMetaTags({
title: pageTitle,
description: copy.description,
imageUrl,
pageUrl,
});
let html = await loadIndexHtml();
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`);
if (html.includes('<!-- embed meta -->')) {
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
} else {
html = html.replace('</head>', ` ${metaBlock}\n </head>`);
}
return html;
}
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
const titleSize = 64;
const subtitleSize = 34;
const statsSize = 30;
const labelSize = 26;
const badgeText = hasFrame ? 'Room cam live' : 'Room cam';
return `
<svg width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(0,0,0,0)" />
<stop offset="50%" stop-color="rgba(0,0,0,0.35)" />
<stop offset="100%" stop-color="rgba(0,0,0,0.85)" />
</linearGradient>
</defs>
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" />
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018">
${escapeXml(badgeText)}
</text>
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
${escapeXml(title)}
</text>
<text x="64" y="455" font-family="DejaVu Sans, Arial, sans-serif" font-size="${subtitleSize}" font-weight="500" fill="#d6e4ff">
${escapeXml(subtitle)}
</text>
<text x="64" y="505" font-family="DejaVu Sans, Arial, sans-serif" font-size="${statsSize}" font-weight="600" fill="#7ef9b2">
${escapeXml(stats)}
</text>
<text x="64" y="552" font-family="DejaVu Sans, Arial, sans-serif" font-size="${labelSize}" font-weight="500" fill="#a7b6d8">
${escapeXml(cameraLabel)}
</text>
</svg>`;
}
async function renderOgImage() {
const state = {
mode: getMode(),
rovers: roverManager.getRoster(),
activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(),
};
const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera);
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
const frame = cameraState?.frame || null;
const hasFrame = Boolean(frame);
const base = frame
? sharp(frame).resize(OG_WIDTH, OG_HEIGHT, { fit: 'cover' })
: sharp({
create: {
width: OG_WIDTH,
height: OG_HEIGHT,
channels: 3,
background: BASE_BG,
},
});
const overlaySvg = Buffer.from(
buildOverlaySvg({
title: copy.title,
subtitle: copy.subtitle,
stats: copy.stats,
cameraLabel: copy.cameraLabel,
hasFrame,
}),
);
const composite = [{ input: overlaySvg, top: 0, left: 0 }];
try {
const logo = await fsp.readFile(BITMAP_PATH);
const logoPng = await sharp(logo).resize(88, 88).png().toBuffer();
composite.push({ input: logoPng, top: 42, left: OG_WIDTH - 130 });
} catch (err) {
// Optional logo; ignore if missing.
}
return base.composite(composite).png().toBuffer();
}
module.exports = {
renderIndexHtml,
renderOgImage,
};
+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,
};
+1 -6
View File
@@ -24,12 +24,7 @@ function enforceLockdown() {
io.on('connection', (socket) => { io.on('connection', (socket) => {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
socket.data.lockdownTimer = setTimeout(() => { disconnectForLockdown(socket);
if (!isLockdownAdmin(socket)) {
disconnectForLockdown(socket);
}
}, 10000);
socket.once('disconnect', () => clearLockdownTimer(socket));
} }
}); });
+7 -1
View File
@@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../globals/io');
const loggerRoot = require('../globals/logger'); const loggerRoot = require('../globals/logger');
const logger = loggerRoot.child('logStream'); const logger = loggerRoot.child('logStream');
const { isBannedSocket } = require('./moderationService');
const MAX_HISTORY = 200; const MAX_HISTORY = 200;
const history = []; const history = [];
@@ -14,11 +15,16 @@ function pushEntry(entry) {
} }
function broadcast(entry) { function broadcast(entry) {
io.emit('log:entry', entry); io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('log:entry', entry);
}
});
} }
function hydrateSocket(socket) { function hydrateSocket(socket) {
if (!socket) return; if (!socket) return;
if (isBannedSocket(socket)) return;
socket.emit('log:init', history); socket.emit('log:init', history);
} }
+4 -3
View File
@@ -1,6 +1,7 @@
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 { publishEvent } = require('./eventBus');
@@ -34,7 +35,7 @@ function setMode(nextMode, socket, options = {}) {
} }
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}`,
}); });
@@ -65,7 +66,7 @@ io.on('connection', (socket) => {
try { try {
setMode(mode, socket); setMode(mode, socket);
} catch (err) { } catch (err) {
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message }); sendAlert({ color: ALERT_COLOR, title: 'Mode change failed', message: err.message });
} }
}); });
}); });
+587
View File
@@ -0,0 +1,587 @@
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const logger = require('../globals/logger').child('moderationService');
const { getRole, roleEvents } = require('./roleService');
const { getNickname, nicknameEvents } = require('./nicknameService');
const { getSocketIp } = require('../helpers/ipResolver');
const { parseCookieHeader } = require('../helpers/cookieParser');
const { logAdminEvent } = require('./adminLogService');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'moderation.json');
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const VISITOR_COOKIE = 'roverd_visitor';
const EVENT_ALLOWLIST = new Set(['auth:login']);
const MAX_HISTORY_ENTRIES = 50;
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 moderation store', err.message);
}
cache = { users: {}, bans: {}, history: [] };
}
if (!cache.users) cache.users = {};
if (!cache.bans) cache.bans = {};
if (!Array.isArray(cache.history)) cache.history = [];
return cache;
}
function isAdminRole(role) {
return ADMIN_ROLES.has(role);
}
function isAdminSocket(socket) {
return isAdminRole(getRole(socket));
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function recordHistory(entry) {
const store = loadStore();
store.history.push(entry);
if (store.history.length > MAX_HISTORY_ENTRIES) {
store.history.shift();
}
}
function parseClientId(socket) {
const auth = socket.handshake?.auth || {};
const clientId =
auth.clientId ||
socket.handshake?.query?.clientId ||
socket.data?.clientId ||
null;
if (typeof clientId === 'string' && clientId.trim()) {
return clientId.trim();
}
return null;
}
function parseVisitorToken(socket) {
const cookies = parseCookieHeader(socket.handshake?.headers?.cookie || '');
const token = cookies[VISITOR_COOKIE];
if (typeof token === 'string' && token.trim()) {
return token.trim();
}
return null;
}
function buildIdentity(socket) {
if (!socket) return {};
return {
clientId: parseClientId(socket),
visitorToken: parseVisitorToken(socket),
ip: getSocketIp(socket),
};
}
function findUserByIdentity(identity) {
const store = loadStore();
const users = Object.values(store.users || {});
return users.find((user) => {
if (identity.clientId && user.clientId === identity.clientId) return true;
if (identity.visitorToken && user.visitorToken === identity.visitorToken) return true;
if (identity.ip && Array.isArray(user.ips) && user.ips.includes(identity.ip)) return true;
return false;
}) || null;
}
function findUserByQuery(query) {
if (!query) return null;
const store = loadStore();
const users = Object.values(store.users || {});
return users.find((user) => {
if (user.id === query) return true;
if (user.clientId === query) return true;
if (user.visitorToken === query) return true;
if (user.lastSocketId === query) return true;
if (Array.isArray(user.socketIds) && user.socketIds.includes(query)) return true;
if (Array.isArray(user.nicknames) && user.nicknames.includes(query)) return true;
if (Array.isArray(user.ips) && user.ips.includes(query)) return true;
return false;
}) || null;
}
function updateUserFromSocket(user, socket, identity) {
let changed = false;
const now = Date.now();
if (!user.firstSeen) {
user.firstSeen = now;
changed = true;
}
if (!user.lastSeen || now > user.lastSeen) {
user.lastSeen = now;
changed = true;
}
const role = getRole(socket);
if (user.lastRole !== role) {
user.lastRole = role;
changed = true;
}
const nickname = getNickname(socket) || null;
if (nickname) {
user.nicknames = Array.isArray(user.nicknames) ? user.nicknames : [];
if (!user.nicknames.includes(nickname)) {
user.nicknames.push(nickname);
changed = true;
}
}
if (identity.clientId && user.clientId !== identity.clientId) {
user.clientId = identity.clientId;
changed = true;
}
if (identity.visitorToken && user.visitorToken !== identity.visitorToken) {
user.visitorToken = identity.visitorToken;
changed = true;
}
if (identity.ip) {
user.ips = Array.isArray(user.ips) ? user.ips : [];
if (!user.ips.includes(identity.ip)) {
user.ips.push(identity.ip);
changed = true;
}
user.lastIp = identity.ip;
}
if (user.lastSocketId !== socket.id) {
user.lastSocketId = socket.id;
changed = true;
}
user.socketIds = Array.isArray(user.socketIds) ? user.socketIds : [];
if (!user.socketIds.includes(socket.id)) {
user.socketIds.push(socket.id);
changed = true;
}
return changed;
}
function ensureUserForSocket(socket) {
const store = loadStore();
const identity = buildIdentity(socket);
let user = findUserByIdentity(identity);
if (!user) {
user = {
id: uuidv4(),
clientId: identity.clientId || null,
visitorToken: identity.visitorToken || null,
ips: identity.ip ? [identity.ip] : [],
nicknames: [],
socketIds: [],
firstSeen: null,
lastSeen: null,
lastRole: null,
lastSocketId: null,
lastIp: identity.ip || null,
};
store.users[user.id] = user;
}
const changed = updateUserFromSocket(user, socket, identity);
socket.data.moderation = { userId: user.id, ...identity };
if (changed) {
saveStore(store);
emitModerationSnapshot();
}
return user;
}
function cleanupExpiredBans() {
const store = loadStore();
const now = Date.now();
let changed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.expiresAt && ban.expiresAt <= now) {
delete store.bans[ban.id];
changed = true;
}
});
if (changed) {
saveStore(store);
}
return changed;
}
function banMatchesIdentity(ban, identity) {
if (!ban) return false;
if (ban.userId && identity.userId && ban.userId === identity.userId) return true;
if (ban.clientId && identity.clientId && ban.clientId === identity.clientId) return true;
if (ban.visitorToken && identity.visitorToken && ban.visitorToken === identity.visitorToken) return true;
if (ban.ip && identity.ip && ban.ip === identity.ip) return true;
return false;
}
function findActiveBan(identity) {
cleanupExpiredBans();
const store = loadStore();
const bans = Object.values(store.bans || {});
return (
bans.find((ban) => {
if (ban.expiresAt && ban.expiresAt <= Date.now()) return false;
return banMatchesIdentity(ban, identity);
}) || null
);
}
function isBannedSocket(socket) {
if (!socket || isAdminSocket(socket)) return false;
const identity = socket.data?.moderation || buildIdentity(socket);
identity.userId = identity.userId || socket.data?.moderation?.userId || null;
return Boolean(findActiveBan(identity));
}
function refreshSocketStatus(socket) {
if (!socket) return;
if (isAdminSocket(socket)) {
if (socket.data?.banInfo) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
}
return;
}
const identity = { ...(socket.data?.moderation || buildIdentity(socket)) };
const ban = findActiveBan(identity);
const prevId = socket.data?.banInfo?.id || null;
if (!ban && prevId) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
return;
}
if (!ban) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
return;
}
if (prevId !== ban.id) {
socket.data.banInfo = ban;
socket.emit('moderation:status', {
banned: true,
reason: ban.reason || null,
expiresAt: ban.expiresAt || null,
createdAt: ban.createdAt || null,
});
}
}
function serializeUser(user) {
const activeBan = findActiveBan({ userId: user.id, clientId: user.clientId, visitorToken: user.visitorToken, ip: user.lastIp });
return {
id: user.id,
clientId: user.clientId || null,
visitorToken: user.visitorToken || null,
ips: user.ips || [],
nicknames: user.nicknames || [],
lastSeen: user.lastSeen || null,
firstSeen: user.firstSeen || null,
lastRole: user.lastRole || null,
lastSocketId: user.lastSocketId || null,
ban: activeBan
? {
id: activeBan.id,
reason: activeBan.reason || null,
createdAt: activeBan.createdAt || null,
expiresAt: activeBan.expiresAt || null,
createdBy: activeBan.createdBy || null,
}
: null,
};
}
function getModerationSnapshot() {
cleanupExpiredBans();
const store = loadStore();
return {
users: Object.values(store.users || {}).map(serializeUser),
bans: Object.values(store.bans || {}),
};
}
function emitModerationSnapshot() {
const payload = getModerationSnapshot();
io.sockets.sockets.forEach((socket) => {
if (!isAdminSocket(socket)) return;
socket.emit('moderation:update', payload);
});
}
function clearBansForUser(user, meta = {}) {
if (!user) return false;
const store = loadStore();
let changed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.userId === user.id) {
delete store.bans[ban.id];
changed = true;
}
if (user.clientId && ban.clientId === user.clientId) {
delete store.bans[ban.id];
changed = true;
}
if (user.visitorToken && ban.visitorToken === user.visitorToken) {
delete store.bans[ban.id];
changed = true;
}
if (user.lastIp && ban.ip === user.lastIp) {
delete store.bans[ban.id];
changed = true;
}
});
if (changed) {
recordHistory({
id: uuidv4(),
action: 'unban',
createdAt: Date.now(),
createdBy: meta.by || null,
reason: meta.reason || null,
userId: user.id,
});
saveStore(store);
}
return changed;
}
function resolveTarget(target = {}) {
if (typeof target === 'string') {
const query = target.trim();
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(query)) {
return { ip: query, query };
}
return { query };
}
return target;
}
function createBan(target, { durationMs = null, reason = null, createdBy = null } = {}) {
cleanupExpiredBans();
const store = loadStore();
const resolved = resolveTarget(target);
const query = resolved.query || null;
const user =
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
const targetSocketId = resolved.socketId || user?.lastSocketId || null;
if (targetSocketId) {
const targetSocket = io.sockets.sockets.get(targetSocketId);
if (targetSocket && isAdminSocket(targetSocket)) {
throw new Error('Active admins cannot be banned.');
}
}
const identity = {
userId: user?.id || null,
clientId: resolved.clientId || user?.clientId || null,
visitorToken: resolved.visitorToken || user?.visitorToken || null,
ip: resolved.ip || user?.lastIp || null,
};
if (!identity.userId && !identity.clientId && !identity.visitorToken && !identity.ip) {
throw new Error('Unknown user.');
}
const cleanReason = typeof reason === 'string' ? reason.trim() : null;
const safeDuration = typeof durationMs === 'number' && durationMs > 0 ? durationMs : null;
const ban = {
id: uuidv4(),
userId: identity.userId,
clientId: identity.clientId,
visitorToken: identity.visitorToken,
ip: identity.ip,
reason: cleanReason || null,
createdAt: Date.now(),
expiresAt: safeDuration ? Date.now() + safeDuration : null,
createdBy: createdBy || null,
};
Object.values(store.bans || {}).forEach((existing) => {
if (banMatchesIdentity(existing, identity)) {
delete store.bans[existing.id];
}
});
store.bans[ban.id] = ban;
recordHistory({
id: uuidv4(),
action: durationMs ? 'timeout' : 'ban',
createdAt: ban.createdAt,
createdBy: ban.createdBy,
reason: ban.reason,
userId: ban.userId || null,
banId: ban.id,
expiresAt: ban.expiresAt,
});
saveStore(store);
return ban;
}
function removeBan(target) {
cleanupExpiredBans();
const store = loadStore();
const resolved = resolveTarget(target);
const banId = resolved.banId || resolved.query;
if (banId && store.bans[banId]) {
delete store.bans[banId];
saveStore(store);
return true;
}
const query = resolved.query || null;
const user =
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
if (user) {
const changed = clearBansForUser(user, { by: resolved.by || null, reason: resolved.reason || null });
if (changed) {
saveStore(store);
}
return changed;
}
if (resolved.ip) {
let removed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.ip === resolved.ip) {
delete store.bans[ban.id];
removed = true;
}
});
if (removed) {
saveStore(store);
}
return removed;
}
return false;
}
function applyBan(target, options) {
const ban = createBan(target, options);
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
return ban;
}
function applyUnban(target) {
const removed = removeBan(target);
if (removed) {
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
}
return removed;
}
function registerSocket(socket) {
const user = ensureUserForSocket(socket);
refreshSocketStatus(socket);
socket.use((packet, next) => {
if (!packet || !packet.length) return next();
const event = packet[0];
if (EVENT_ALLOWLIST.has(event)) return next();
if (isAdminSocket(socket)) return next();
if (isBannedSocket(socket)) {
return next(new Error('banned'));
}
return next();
});
socket.on('disconnect', () => {
const store = loadStore();
if (!store.users[user.id]) return;
store.users[user.id].lastSeen = Date.now();
saveStore(store);
});
}
io.on('connection', (socket) => {
registerSocket(socket);
if (isAdminSocket(socket)) {
socket.emit('moderation:init', getModerationSnapshot());
}
socket.on('moderation:ban', ({ target, durationMs, reason } = {}, cb = () => {}) => {
if (!isAdminSocket(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const ban = applyBan(target, {
durationMs: durationMs || null,
reason,
createdBy: socket?.data?.user?.username || socket.id,
});
logAdminEvent({
label: 'moderation',
message: durationMs ? 'User timed out' : 'User banned',
ip: socket?.data?.moderation?.ip || null,
meta: { target, reason, expiresAt: ban.expiresAt || null },
socketId: socket.id,
});
cb({ success: true, ban });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('moderation:unban', ({ target } = {}, cb = () => {}) => {
if (!isAdminSocket(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const removed = applyUnban({ ...target, by: socket?.data?.user?.username || socket.id });
if (removed) {
logAdminEvent({
label: 'moderation',
message: 'User unbanned',
ip: socket?.data?.moderation?.ip || null,
meta: { target },
socketId: socket.id,
});
}
cb({ success: true, removed });
} catch (err) {
cb({ error: err.message });
}
});
});
roleEvents.on('change', ({ socket, role }) => {
if (!socket) return;
if (ADMIN_ROLES.has(role)) {
ensureUserForSocket(socket);
refreshSocketStatus(socket);
socket.emit('moderation:init', getModerationSnapshot());
}
});
nicknameEvents.on('change', ({ socketId }) => {
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
if (socket) {
const store = loadStore();
const identity = buildIdentity(socket);
const user = findUserByIdentity(identity);
if (user) {
const changed = updateUserFromSocket(user, socket, identity);
if (changed) {
saveStore(store);
emitModerationSnapshot();
}
}
}
});
setInterval(() => {
const expired = cleanupExpiredBans();
if (expired) {
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
}
}, 30 * 1000);
module.exports = {
buildIdentity,
getModerationSnapshot,
isBannedSocket,
findUserByQuery,
createBan,
removeBan,
applyBan,
applyUnban,
refreshSocketStatus,
};
+2 -1
View File
@@ -9,7 +9,8 @@ function sanitizeNickname(raw) {
if (typeof raw !== 'string') return ''; if (typeof raw !== 'string') return '';
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return ''; if (!trimmed) return '';
return trimmed.slice(0, 32); const sanitized = trimmed.replace(/\*/g, 'nope');
return sanitized.slice(0, 32);
} }
function getNickname(socket) { function getNickname(socket) {
+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,
};
+304
View File
@@ -0,0 +1,304 @@
const { spawn } = require('child_process');
const fsp = require('fs/promises');
const path = require('path');
const logger = require('../globals/logger').child('replaySegments');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const roverManager = require('./roverManager');
const SEGMENT_DIR = process.env.REPLAY_SEGMENT_DIR || '/var/lib/replay-segments';
const SEGMENT_SECONDS = 2;
const BUFFER_SECONDS = 40;
const CLEANUP_INTERVAL_MS = 20000;
const FPS = 15;
const SCALE_WIDTH = 640;
const MAX_BYTES = Number.parseInt(process.env.REPLAY_SEGMENT_MAX_BYTES || '0', 10);
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const ROVER_SNAPSHOT_FPS = 3;
const recorders = new Map(); // key -> { proc, source }
let cleanupTimer = null;
function sourceKey(source) {
return `${source.type}__${source.id}`;
}
function getRoomCameraStream(camera) {
if (camera.streamUrl) return camera.streamUrl;
const url = String(camera.url || '');
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
return url;
}
return null;
}
function listSources() {
const rooms = getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
streamUrl: getRoomCameraStream(camera),
}));
const rovers = roverManager.getRoster().map((rover) => ({
type: 'rover',
id: String(rover.id),
label: rover.name || rover.id,
}));
return [...rooms, ...rovers];
}
function buildInputUrl(source) {
if (source.type === 'room') {
return source.streamUrl || null;
}
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(source.id)}`;
}
async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true });
}
function buildOutputPattern(key) {
return path.join(SEGMENT_DIR, key, '%Y%m%d-%H%M%S.mp4');
}
function spawnRecorder(source) {
const key = sourceKey(source);
const inputUrl = buildInputUrl(source);
if (!inputUrl) {
logger.warn('Replay recorder missing input URL', { key, source });
return;
}
const outPattern = buildOutputPattern(key);
const outputSnapshot = source.type === 'rover'
? path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`)
: null;
ensureDir(path.dirname(outPattern))
.then(async () => {
if (outputSnapshot) {
await ensureDir(ROVER_SNAPSHOT_DIR);
}
const args = [
'-hide_banner',
'-loglevel',
'info',
'-y',
'-fflags',
'nobuffer',
'-flags',
'low_delay',
'-i',
inputUrl,
];
if (outputSnapshot) {
args.push(
'-filter_complex',
`[0:v]fps=${FPS},scale=${SCALE_WIDTH}:-1[vseg];` +
`[0:v]fps=${ROVER_SNAPSHOT_FPS},scale=${SCALE_WIDTH}:-1[vjpg]`,
'-map',
'[vseg]',
'-an',
'-c:v:0',
'libx264',
'-preset',
'veryfast',
'-tune',
'zerolatency',
'-g',
String(FPS),
'-keyint_min',
String(FPS),
'-sc_threshold',
'0',
'-f',
'segment',
'-segment_time',
String(SEGMENT_SECONDS),
'-segment_format',
'mp4',
'-reset_timestamps',
'1',
'-strftime',
'1',
outPattern,
'-map',
'[vjpg]',
'-an',
'-q:v:1',
'8',
'-f',
'image2',
'-update',
'1',
outputSnapshot,
);
} else {
args.push(
'-r',
String(FPS),
'-vf',
`fps=${FPS},scale=${SCALE_WIDTH}:-1`,
'-an',
'-c:v',
'libx264',
'-preset',
'veryfast',
'-tune',
'zerolatency',
'-g',
String(FPS),
'-keyint_min',
String(FPS),
'-sc_threshold',
'0',
'-f',
'segment',
'-segment_time',
String(SEGMENT_SECONDS),
'-segment_format',
'mp4',
'-reset_timestamps',
'1',
'-strftime',
'1',
outPattern,
);
}
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
const stderrChunks = [];
let stderrSize = 0;
proc.stderr.on('data', (chunk) => {
if (!chunk || stderrSize > 8192) return;
stderrChunks.push(chunk);
stderrSize += chunk.length;
});
recorders.set(key, { proc, source });
proc.on('exit', (code, signal) => {
recorders.delete(key);
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
if (stderrBuffer && stderrBuffer.length) {
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
logger.warn('Replay recorder stderr', {
key,
stderrBytes: stderrBuffer.length,
stderrPreview: preview || '<non-utf8>',
});
} else {
logger.warn('Replay recorder stderr', { key, stderrBytes: 0 });
}
if (!shouldRecord(source)) {
return;
}
const delay = 2000;
logger.warn('Replay recorder exited; restarting', { key, code, signal });
setTimeout(() => {
if (!recorders.has(key) && shouldRecord(source)) {
spawnRecorder(source);
}
}, delay);
});
})
.catch((err) => {
logger.warn('Replay recorder setup failed', { key, err: err.message });
});
}
function stopRecorder(key) {
const entry = recorders.get(key);
if (!entry) return;
entry.proc.kill('SIGTERM');
recorders.delete(key);
}
function shouldRecord(source) {
if (source.type === 'room') {
return Boolean(source.streamUrl);
}
return true;
}
function syncRecorders() {
const sources = listSources();
const desiredKeys = new Set();
sources.forEach((source) => {
if (!shouldRecord(source)) return;
const key = sourceKey(source);
desiredKeys.add(key);
if (!recorders.has(key)) {
spawnRecorder(source);
}
});
Array.from(recorders.keys()).forEach((key) => {
if (!desiredKeys.has(key)) {
stopRecorder(key);
}
});
}
async function cleanupSegments() {
try {
await ensureDir(SEGMENT_DIR);
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
const entries = await fsp.readdir(SEGMENT_DIR, { withFileTypes: true });
let totalBytes = 0;
const files = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirPath = path.join(SEGMENT_DIR, entry.name);
const inner = await fsp.readdir(dirPath, { withFileTypes: true });
for (const file of inner) {
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
const filePath = path.join(dirPath, file.name);
const stats = await fsp.stat(filePath);
totalBytes += stats.size;
files.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size });
if (stats.mtimeMs < cutoff) {
await fsp.unlink(filePath);
}
}
const remaining = await fsp.readdir(dirPath);
if (!remaining.length) {
await fsp.rmdir(dirPath);
}
}
if (MAX_BYTES > 0 && totalBytes > MAX_BYTES) {
const overBy = totalBytes - MAX_BYTES;
let freed = 0;
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const file of files) {
if (freed >= overBy) break;
try {
await fsp.unlink(file.filePath);
freed += file.size;
} catch {
// ignore
}
}
}
} catch (err) {
logger.warn('Replay cleanup failed', err.message);
}
}
function start() {
syncRecorders();
cleanupSegments();
if (cleanupTimer) clearInterval(cleanupTimer);
cleanupTimer = setInterval(cleanupSegments, CLEANUP_INTERVAL_MS);
}
roomCameraEvents.on('update', () => {
syncRecorders();
});
roverManager.managerEvents.on('rover', () => {
syncRecorders();
});
start();
module.exports = {
replaySegmentsDir: SEGMENT_DIR,
segmentSeconds: SEGMENT_SECONDS,
bufferSeconds: BUFFER_SECONDS,
};
+42
View File
@@ -0,0 +1,42 @@
const EventEmitter = require('events');
const COOLDOWN_MS = 10 * 1000;
const events = new EventEmitter();
let lastTriggeredAt = null;
let lastTriggeredBy = null;
function getRemainingMs(now = Date.now()) {
if (!lastTriggeredAt) return 0;
const elapsed = now - lastTriggeredAt;
return Math.max(0, COOLDOWN_MS - elapsed);
}
function getState() {
const remainingMs = getRemainingMs();
return {
cooldownMs: COOLDOWN_MS,
lastTriggeredAt,
lastTriggeredBy,
remainingMs,
available: remainingMs === 0,
};
}
function tryTrigger(by = null) {
const remainingMs = getRemainingMs();
if (remainingMs > 0) {
return { ok: false, remainingMs, state: getState() };
}
lastTriggeredAt = Date.now();
lastTriggeredBy = by || null;
const state = getState();
events.emit('update', { state, by: lastTriggeredBy });
return { ok: true, state };
}
module.exports = {
tryTriggerReplay: tryTrigger,
getReplayState: getState,
replayEvents: events,
};
@@ -0,0 +1,58 @@
const io = require('../globals/io');
const logger = require('../globals/logger').child('replaySocket');
const { getMode, MODES } = require('./modeManager');
const { publishEvent } = require('./eventBus');
const { tryTriggerReplay } = require('./replayService');
const { validateSources, getDefaultWebSources } = require('./replaySourceService');
const assignmentService = require('./assignmentService');
const { getNickname } = require('./nicknameService');
const { loadConfig } = require('../helpers/configLoader');
const config = loadConfig();
const discordConfig = config.discord || {};
function buildRequesterLabel(socket) {
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
}
io.on('connection', (socket) => {
socket.on('replay:trigger', (payload = {}, cb = () => {}) => {
if (getMode() === MODES.LOCKDOWN) {
cb({ error: 'Replay disabled in lockdown', state: null });
return;
}
const channelId = discordConfig?.channels?.replay || null;
if (!channelId) {
cb({ error: 'Replay channel not configured', state: null });
return;
}
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
let sources = requestedSources ? validateSources(requestedSources) : [];
if (!sources.length) {
const assignment = assignmentService.describeAssignment(socket.id);
sources = getDefaultWebSources(assignment);
}
if (!sources.length) {
cb({ error: 'No replay sources selected', state: null });
return;
}
const requester = buildRequesterLabel(socket);
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
if (!attempt.ok) {
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
return;
}
publishEvent({
source: 'replaySocket',
type: 'replay.requested',
payload: {
channelId,
requester,
sources,
requestedBy: { socketId: socket.id },
},
});
logger.info('Replay requested via web', { socketId: socket.id });
cb({ success: true, state: attempt.state });
});
});
@@ -0,0 +1,72 @@
const roverManager = require('./roverManager');
const { getRoomCameras } = require('./roomCameraService');
function getReplaySources() {
const roverSources = roverManager.getRoster().map((rover) => ({
type: 'rover',
id: String(rover.id),
label: rover.name || rover.id,
}));
const roomSources = getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
}));
return [...roverSources, ...roomSources];
}
function normalizeSource(entry) {
if (!entry) return null;
if (typeof entry === 'string') {
const [type, id] = entry.split(':');
if (!type || !id) return null;
return { type, id: String(id) };
}
if (typeof entry === 'object') {
if (entry.type && entry.id) {
return { type: entry.type, id: String(entry.id) };
}
}
return null;
}
function validateSources(list = []) {
const allowed = new Map();
getReplaySources().forEach((source) => {
allowed.set(`${source.type}:${source.id}`, source);
});
const unique = new Map();
(Array.isArray(list) ? list : []).forEach((entry) => {
const normalized = normalizeSource(entry);
if (!normalized) return;
const key = `${normalized.type}:${normalized.id}`;
const source = allowed.get(key);
if (!source) return;
unique.set(key, { type: source.type, id: source.id, label: source.label });
});
return Array.from(unique.values());
}
function getDefaultWebSources(assignment = {}) {
if (assignment?.roverId) {
const id = String(assignment.roverId);
const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id);
return [{ type: 'rover', id, label: match?.label || id }];
}
return [];
}
function getDefaultDiscordSources() {
return getRoomCameras().map((camera) => ({
type: 'room',
id: String(camera.id),
label: camera.name || camera.id,
}));
}
module.exports = {
getReplaySources,
validateSources,
getDefaultWebSources,
getDefaultDiscordSources,
};
@@ -0,0 +1,250 @@
const { execFile } = require('child_process');
const EventEmitter = require('events');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const { promisify } = require('util');
const logger = require('../globals/logger').child('roomCameraReplay');
const { getRoomCamera, getRoomCameras } = require('./roomCameraService');
const execFileAsync = promisify(execFile);
const REPLAY_DURATION_MS = 20000;
const REPLAY_FPS = 15;
const HISTORY_WINDOW_MS = 60000;
const MAX_REPLAY_WIDTH = 1280;
const MAX_REPLAY_HEIGHT = 720;
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
const frameHistory = new Map(); // id -> [{ buffer, ts }]
const latestFrames = new Map(); // id -> { buffer, ts }
const events = new EventEmitter();
function recordFrame(id, buffer, ts = Date.now()) {
if (!id || !buffer) return;
const entry = { buffer, ts };
latestFrames.set(id, entry);
const history = frameHistory.get(id) || [];
history.push(entry);
const cutoff = ts - HISTORY_WINDOW_MS;
while (history.length && history[0].ts < cutoff) {
history.shift();
}
frameHistory.set(id, history);
events.emit('frame', { id, ts });
}
function clearFrames() {
frameHistory.clear();
latestFrames.clear();
}
function getReplayMetadata() {
return {
durationMs: REPLAY_DURATION_MS,
fps: REPLAY_FPS,
};
}
function buildTimelineForCamera(id, startMs, frameCount, frameStepMs) {
const history = frameHistory.get(id) || [];
const fallback = latestFrames.get(id)?.buffer || null;
if (!history.length && !fallback) return null;
let idx = 0;
let lastBuffer = null;
while (idx < history.length && history[idx].ts < startMs) {
lastBuffer = history[idx].buffer;
idx += 1;
}
if (!lastBuffer) {
lastBuffer = history[0]?.buffer || fallback;
}
const frames = new Array(frameCount);
for (let i = 0; i < frameCount; i += 1) {
const slotTs = startMs + i * frameStepMs;
while (idx < history.length && history[idx].ts <= slotTs) {
lastBuffer = history[idx].buffer;
idx += 1;
}
frames[i] = lastBuffer || fallback;
}
return frames;
}
function buildGridLayout(count) {
const cols = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / cols);
return { cols, rows };
}
async function probeMaxFrameSize(framePaths) {
let maxWidth = 0;
let maxHeight = 0;
for (const framePath of framePaths) {
try {
const { stdout } = await execFileAsync('ffprobe', [
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height',
'-of',
'csv=p=0',
framePath,
]);
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 frame size', err.message);
}
}
return { maxWidth, maxHeight };
}
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`;
}
function clampEven(value) {
return Math.max(2, Math.floor(value / 2) * 2);
}
async function buildReplayVideo({ cameraId = null } = {}) {
const cameras = cameraId ? [getRoomCamera(cameraId)].filter(Boolean) : getRoomCameras();
if (!cameras.length) {
throw new Error('No room cameras configured');
}
const fpsValue = REPLAY_FPS;
const frameCount = Math.max(1, Math.round((REPLAY_DURATION_MS / 1000) * fpsValue));
const frameStepMs = 1000 / fpsValue;
const startMs = Date.now() - REPLAY_DURATION_MS;
const cameraEntries = [];
cameras.forEach((camera) => {
const frames = buildTimelineForCamera(camera.id, startMs, frameCount, frameStepMs);
if (!frames) return;
cameraEntries.push({ camera, frames });
});
if (!cameraEntries.length) {
throw new Error('No camera frames available yet');
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
try {
const firstFramePaths = [];
for (let i = 0; i < cameraEntries.length; i += 1) {
const entry = cameraEntries[i];
const camDir = path.join(tmpDir, `cam-${String(i).padStart(2, '0')}`);
entry.dir = camDir;
await fsp.mkdir(camDir, { recursive: true });
for (let j = 0; j < entry.frames.length; j += 1) {
const buffer = entry.frames[j];
if (!buffer) {
throw new Error(`Camera ${entry.camera.id} missing replay frame`);
}
const filename = `frame-${String(j + 1).padStart(4, '0')}.jpg`;
const fullPath = path.join(camDir, filename);
if (j === 0) {
firstFramePaths.push(fullPath);
}
await fsp.writeFile(fullPath, buffer);
}
}
const { maxWidth, maxHeight } = await probeMaxFrameSize(firstFramePaths);
const layout = buildGridLayout(cameraEntries.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 = tileWidth * scale;
tileHeight = 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 fps = fpsValue.toFixed(3);
const durationSec = Math.max(1, frameCount / fpsValue);
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 inputArgs = [];
const filterParts = [];
const layoutParts = [];
for (let i = 0; i < cameraEntries.length; i += 1) {
inputArgs.push('-framerate', fps, '-i', path.join(cameraEntries[i].dir, 'frame-%04d.jpg'));
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 (cameraEntries.length === 1) {
filterParts.push('[v0]null[v]');
} else {
filterParts.push(
`${cameraEntries.map((_, i) => `[v${i}]`).join('')}` +
`xstack=inputs=${cameraEntries.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
);
}
const outPath = path.join(tmpDir, 'replay.mp4');
await execFileAsync('ffmpeg', [
'-y',
'-hide_banner',
'-loglevel',
'error',
...inputArgs,
'-filter_complex',
filterParts.join(';'),
'-map',
'[v]',
'-r',
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;
} finally {
try {
await fsp.rm(tmpDir, { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to cleanup replay temp dir', err.message);
}
}
}
module.exports = {
recordRoomCameraFrame: recordFrame,
clearRoomCameraReplayFrames: clearFrames,
getRoomCameraReplayMetadata: getReplayMetadata,
buildRoomCameraReplayVideo: buildReplayVideo,
roomCameraReplayEvents: events,
};
+4 -3
View File
@@ -14,15 +14,16 @@ function normalizeCamera(camera) {
logger.warn('Room camera missing id', camera); logger.warn('Room camera missing id', camera);
return null; return null;
} }
if (!camera.url) { if (!camera.url && !camera.streamUrl && !camera.mjpegUrl) {
logger.warn('Room camera missing url', { id, camera }); logger.warn('Room camera missing url/streamUrl', { id, camera });
return null; return null;
} }
return { return {
id: String(id), id: String(id),
name: camera.name || camera.id || String(id), name: camera.name || camera.id || String(id),
description: camera.description || null, description: camera.description || null,
url: camera.url, url: camera.url || null,
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
}; };
} }
+147 -17
View File
@@ -1,27 +1,149 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const http = require('http');
const https = require('https');
const logger = require('../globals/logger').child('roomCameraSnapshot'); const logger = require('../globals/logger').child('roomCameraSnapshot');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const POLL_INTERVAL_MS = 500; // 2 fps target const POLL_INTERVAL_MS = 67;
const FETCH_TIMEOUT_MS = 2000; const FETCH_TIMEOUT_MS = 2000;
const STALE_AFTER_MS = 4000; const STREAM_RETRY_MS = 1500;
const cameraState = new Map(); // id -> {frame, ts, stale, error, failures, fetching} const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
const events = new EventEmitter(); // frame, status const events = new EventEmitter(); // frame, status
let pollTimer = null; let pollTimer = null;
const streamState = new Map(); // id -> { req, reconnectTimer }
const JPEG_START = Buffer.from([0xff, 0xd8]);
const JPEG_END = Buffer.from([0xff, 0xd9]);
function markState(id, updates = {}) { function markState(id, updates = {}) {
const prev = cameraState.get(id) || {}; const prev = cameraState.get(id) || {};
const next = { ...prev, ...updates }; const next = { ...prev, ...updates };
if (next.ts != null) {
next.stale = Date.now() - next.ts > STALE_AFTER_MS || !!next.stale;
} else {
next.stale = true;
}
cameraState.set(id, next); cameraState.set(id, next);
return next; return next;
} }
function isMjpegUrl(rawUrl) {
if (!rawUrl) return false;
const lower = String(rawUrl).toLowerCase();
return lower.endsWith('.mjpg') || lower.endsWith('.mjpeg') || lower.includes('mjpeg');
}
function getStreamUrl(camera) {
if (camera.streamUrl) return camera.streamUrl;
if (isMjpegUrl(camera.url)) return camera.url;
return null;
}
function stopStream(id) {
const entry = streamState.get(id);
if (!entry) return;
if (entry.req) {
entry.req.destroy();
}
if (entry.reconnectTimer) {
clearTimeout(entry.reconnectTimer);
}
streamState.delete(id);
}
function scheduleStreamReconnect(camera) {
const id = camera.id;
const entry = streamState.get(id) || {};
if (entry.reconnectTimer) return;
entry.reconnectTimer = setTimeout(() => {
const current = streamState.get(id) || {};
current.reconnectTimer = null;
streamState.set(id, current);
startStream(camera);
}, STREAM_RETRY_MS);
streamState.set(id, entry);
}
function clearStreamRequest(id, req) {
const entry = streamState.get(id);
if (!entry) return;
if (entry.req !== req) return;
entry.req = null;
streamState.set(id, entry);
}
function handleStreamError(camera, err) {
const state = cameraState.get(camera.id) || {};
const failures = (state.failures || 0) + 1;
markState(camera.id, { error: err.message || 'stream error', failures });
events.emit('status', { id: camera.id, error: err.message || 'stream error' });
logger.warn('Stream failed', { id: camera.id, err: err.message });
}
function startStream(camera) {
const streamUrl = getStreamUrl(camera);
if (!streamUrl) return;
if (streamState.get(camera.id)?.req) return;
const url = new URL(streamUrl);
const client = url.protocol === 'https:' ? https : http;
const req = client.get(
{
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers: { Accept: 'multipart/x-mixed-replace' },
},
(res) => {
if (res.statusCode !== 200) {
res.resume();
clearStreamRequest(camera.id, req);
handleStreamError(camera, new Error(`HTTP ${res.statusCode}`));
scheduleStreamReconnect(camera);
return;
}
let buffer = Buffer.alloc(0);
res.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
let start = buffer.indexOf(JPEG_START);
if (start === -1) {
if (buffer.length > 2 * 1024 * 1024) {
buffer = buffer.slice(-1024 * 1024);
}
break;
}
if (start > 0) {
buffer = buffer.slice(start);
start = 0;
}
const end = buffer.indexOf(JPEG_END, 2);
if (end === -1) break;
const frame = buffer.slice(0, end + 2);
buffer = buffer.slice(end + 2);
const ts = Date.now();
markState(camera.id, { frame, ts, error: null, failures: 0 });
events.emit('frame', { id: camera.id, buffer: frame, ts });
}
});
res.on('end', () => {
clearStreamRequest(camera.id, req);
scheduleStreamReconnect(camera);
});
res.on('aborted', () => {
clearStreamRequest(camera.id, req);
handleStreamError(camera, new Error('stream aborted'));
scheduleStreamReconnect(camera);
});
res.on('error', (err) => {
clearStreamRequest(camera.id, req);
handleStreamError(camera, err);
scheduleStreamReconnect(camera);
});
},
);
req.on('error', (err) => {
clearStreamRequest(camera.id, req);
handleStreamError(camera, err);
scheduleStreamReconnect(camera);
});
streamState.set(camera.id, { req, reconnectTimer: null });
}
async function fetchSnapshot(camera) { async function fetchSnapshot(camera) {
const { id, url } = camera; const { id, url } = camera;
const state = cameraState.get(id); const state = cameraState.get(id);
@@ -37,8 +159,8 @@ async function fetchSnapshot(camera) {
const arrayBuffer = await res.arrayBuffer(); const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const ts = Date.now(); const ts = Date.now();
const next = markState(id, { frame: buffer, ts, error: null, failures: 0 }); markState(id, { frame: buffer, ts, error: null, failures: 0 });
events.emit('frame', { id, buffer, ts, stale: !!next.stale }); events.emit('frame', { id, buffer, ts });
} catch (err) { } catch (err) {
const failures = (state?.failures || 0) + 1; const failures = (state?.failures || 0) + 1;
markState(id, { error: err.message, failures }); markState(id, { error: err.message, failures });
@@ -55,26 +177,34 @@ function stopAll() {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
} }
Array.from(streamState.keys()).forEach((id) => stopStream(id));
cameraState.clear(); cameraState.clear();
} }
function startAll() { function startAll() {
stopAll(); stopAll();
pollTimer = setInterval(() => { const cameras = getRoomCameras();
getRoomCameras().forEach((camera) => fetchSnapshot(camera)); const snapshotCameras = cameras.filter((camera) => !getStreamUrl(camera) && camera.url);
}, POLL_INTERVAL_MS); cameras.forEach((camera) => startStream(camera));
getRoomCameras().forEach((camera) => fetchSnapshot(camera)); if (snapshotCameras.length) {
logger.info('Started snapshot polling', { count: getRoomCameras().length }); pollTimer = setInterval(() => {
snapshotCameras.forEach((camera) => fetchSnapshot(camera));
}, POLL_INTERVAL_MS);
snapshotCameras.forEach((camera) => fetchSnapshot(camera));
}
logger.info('Started room camera feeds', {
total: cameras.length,
streaming: cameras.length - snapshotCameras.length,
snapshots: snapshotCameras.length,
});
} }
function getState(id) { function getState(id) {
const state = cameraState.get(id); const state = cameraState.get(id);
if (!state) return null; if (!state) return null;
const stale = state.ts == null || Date.now() - state.ts > STALE_AFTER_MS;
return { return {
frame: state.frame || null, frame: state.frame || null,
ts: state.ts || null, ts: state.ts || null,
stale,
error: state.error || null, error: state.error || null,
}; };
} }
+17 -4
View File
@@ -7,6 +7,7 @@ const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnap
const SUBSCRIBE_LIMIT = 50; const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000; const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 800;
function passesMode(socket) { function passesMode(socket) {
const mode = getMode(); const mode = getMode();
@@ -27,6 +28,7 @@ function canViewRoomCamera(socket) {
const cameraSubscribers = new Map(); // id -> Set(socketId) const cameraSubscribers = new Map(); // id -> Set(socketId)
const socketSubscriptions = new Map(); // socketId -> Set(id) const socketSubscriptions = new Map(); // socketId -> Set(id)
const subscribeBuckets = new Map(); // socketId -> { start, count } const subscribeBuckets = new Map(); // socketId -> { start, count }
const lastSentBySocket = new Map(); // socketId -> Map(cameraId -> ts)
function addSubscription(socket, cameraId) { function addSubscription(socket, cameraId) {
if (!cameraSubscribers.has(cameraId)) { if (!cameraSubscribers.has(cameraId)) {
@@ -82,13 +84,24 @@ function sendStatus(socket, cameraId, status) {
socket.emit('roomCamera:status', { id: cameraId, ...status }); socket.emit('roomCamera:status', { id: cameraId, ...status });
} }
roomCameraStreamEvents.on('frame', ({ id, buffer, ts, stale }) => { roomCameraStreamEvents.on('frame', ({ id, buffer, ts }) => {
const bucket = cameraSubscribers.get(id); const bucket = cameraSubscribers.get(id);
if (!bucket || !buffer) return; if (!bucket || !buffer) return;
bucket.forEach((socketId) => { bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId); const socket = io.sockets.sockets.get(socketId);
if (!socket) return; if (!socket) return;
sendFrame(socket, id, { ts, stale: !!stale }, buffer); let lastMap = lastSentBySocket.get(socketId);
if (!lastMap) {
lastMap = new Map();
lastSentBySocket.set(socketId, lastMap);
}
const lastSent = lastMap.get(id) || 0;
const now = ts || Date.now();
if (now - lastSent < STREAM_INTERVAL_MS) {
return;
}
lastMap.set(id, now);
sendFrame(socket, id, { ts }, buffer);
}); });
}); });
@@ -123,11 +136,10 @@ io.on('connection', (socket) => {
validIds.forEach((cameraId) => { validIds.forEach((cameraId) => {
const state = getRoomCameraState(cameraId); const state = getRoomCameraState(cameraId);
if (state?.frame) { if (state?.frame) {
sendFrame(socket, cameraId, { ts: state.ts, stale: !!state.stale }, state.frame); sendFrame(socket, cameraId, { ts: state.ts }, state.frame);
} }
sendStatus(socket, cameraId, { sendStatus(socket, cameraId, {
ts: state?.ts || null, ts: state?.ts || null,
stale: state?.stale ?? true,
error: state?.error || null, error: state?.error || null,
}); });
}); });
@@ -150,5 +162,6 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => { socket.on('disconnect', () => {
removeAllSubscriptions(socket.id); removeAllSubscriptions(socket.id);
subscribeBuckets.delete(socket.id); subscribeBuckets.delete(socket.id);
lastSentBySocket.delete(socket.id);
}); });
}); });
@@ -1,20 +1,21 @@
const roverWSS = require('../globals/ws'); const roverWSS = require('../globals/ws');
const logger = require('../globals/logger').child('roverConnection'); const logger = require('../globals/logger').child('roverConnection');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { sendAlert, COLORS } = require('./alertService'); const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#00bcd4';
const { handleAck } = require('./commandService'); const { handleAck } = require('./commandService');
function handleMessage(roverId, msg) { function handleMessage(roverId, msg) {
switch (msg.type) { switch (msg.type) {
case 'hello': case 'hello':
roverManager.upsertRover(msg, this); roverManager.upsertRover(msg, this);
sendAlert({ color: COLORS.info, title: 'Rover Connected', message: roverId }); sendAlert({ color: ALERT_COLOR, title: 'Rover Connected', message: roverId });
break; break;
case 'sensor': case 'sensor':
roverManager.handleSensorFrame(roverId, msg); roverManager.handleSensorFrame(roverId, msg);
break; break;
case 'event': case 'event':
sendAlert({ color: COLORS.info, title: `${roverId} event`, message: msg.event }); sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
break; break;
default: default:
break; break;
@@ -40,7 +41,7 @@ roverWSS.on('connection', (ws) => {
}); });
roverManager.upsertRover(msg, ws); roverManager.upsertRover(msg, ws);
roverManager.broadcastRoster(); roverManager.broadcastRoster();
sendAlert({ color: COLORS.success, title: 'Rover Online', message: roverId }); sendAlert({ color: ALERT_COLOR, title: 'Rover Online', message: roverId });
return; return;
} }
if (!roverId) return; if (!roverId) return;
@@ -49,14 +50,14 @@ roverWSS.on('connection', (ws) => {
} else if (msg.type === 'ack') { } else if (msg.type === 'ack') {
handleAck(msg); handleAck(msg);
} else if (msg.type === 'event') { } else if (msg.type === 'event') {
sendAlert({ color: COLORS.info, title: `${roverId}`, message: msg.event }); sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
} }
}); });
ws.on('close', () => { ws.on('close', () => {
if (roverId) { if (roverId) {
roverManager.removeRover(roverId); roverManager.removeRover(roverId);
sendAlert({ color: COLORS.warn, title: 'Rover Offline', message: roverId }); sendAlert({ color: ALERT_COLOR, title: 'Rover Offline', message: roverId });
} }
}); });
}); });
+320 -14
View File
@@ -1,17 +1,29 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const logger = require('../globals/logger').child('roverManager'); const logger = require('../globals/logger').child('roverManager');
const { sendAlert, COLORS } = require('./alertService'); const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#8bc34a';
const { parseSensorFrame } = require('../helpers/sensorDecoder'); const { parseSensorFrame } = require('../helpers/sensorDecoder');
const { MODES, getMode } = require('./modeManager'); const { MODES, getMode } = require('./modeManager');
const { isAdmin, roleEvents } = require('./roleService'); const { isAdmin, roleEvents } = require('./roleService');
const { isBannedSocket } = require('./moderationService');
const { publishEvent } = require('./eventBus'); const { publishEvent } = require('./eventBus');
const videoSessions = require('./videoSessions');
const rovers = new Map(); // roverId -> record const rovers = new Map(); // roverId -> record
const socketToRovers = new Map(); // socketId -> Set(roverId) const socketToRovers = new Map(); // socketId -> Set(roverId)
const spectatorSockets = new Set(); const spectatorSockets = new Set();
const turnService = require('./turnService'); const turnService = require('./turnService');
const managerEvents = new EventEmitter(); const managerEvents = new EventEmitter();
const DOCK_GUARD_WINDOW_MS = 2 * 1000;
const IDLE_UNDOCKED_MS = 2 * 60 * 1000;
const PASSIVE_UNDOCKED_MS = 60 * 1000;
const DOCK_GUARD_RETRY_MS = 10 * 1000;
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
const BACKOFF_MS = 500;
const BACKOFF_SPEED = 300;
const backoffTimers = new Map(); // roverId -> Timeout
const dockGuardStates = new Map(); // roverId -> guard state
function ensureRecord(id) { function ensureRecord(id) {
if (!rovers.has(id)) { if (!rovers.has(id)) {
@@ -20,12 +32,15 @@ function ensureRecord(id) {
meta: null, meta: null,
ws: null, ws: null,
lastSensor: null, lastSensor: null,
docked: null,
lastBumpAt: null,
drivers: new Set(), drivers: new Set(),
locked: false, locked: false,
lockReason: null, lockReason: null,
batteryState: null, batteryState: null,
room: `rover:${id}`, room: `rover:${id}`,
lastSeen: Date.now(), lastSeen: Date.now(),
lastMovementAt: Date.now(),
}); });
} }
return rovers.get(id); return rovers.get(id);
@@ -60,6 +75,7 @@ function removeRover(id) {
const record = rovers.get(id); const record = rovers.get(id);
if (!record) return; if (!record) return;
rovers.delete(id); rovers.delete(id);
stopDockGuard(id);
turnService.cleanupRover(id); turnService.cleanupRover(id);
spectatorSockets.forEach((socketId) => { spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId); const sock = io.sockets.sockets.get(socketId);
@@ -75,6 +91,7 @@ function lockRover(id, locked, options = {}) {
if (!record) { if (!record) {
throw new Error('Unknown rover'); throw new Error('Unknown rover');
} }
const wasAllUnlocked = Array.from(rovers.values()).every((entry) => !entry.locked);
const reason = locked ? options.reason || 'manual' : null; const reason = locked ? options.reason || 'manual' : null;
const silent = Boolean(options.silent); const silent = Boolean(options.silent);
if (locked) { if (locked) {
@@ -82,7 +99,7 @@ function lockRover(id, locked, options = {}) {
record.lockReason = reason; record.lockReason = reason;
if (!silent) { if (!silent) {
sendAlert({ sendAlert({
color: COLORS.warn, color: ALERT_COLOR,
title: 'Rover Locked', title: 'Rover Locked',
message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`, message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`,
}); });
@@ -96,13 +113,21 @@ function lockRover(id, locked, options = {}) {
record.locked = false; record.locked = false;
record.lockReason = null; record.lockReason = null;
if (!silent) { if (!silent) {
sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` }); sendAlert({ color: ALERT_COLOR, title: 'Rover Unlocked', message: `${id} unlocked.` });
} }
publishEvent({ publishEvent({
source: 'roverManager', source: 'roverManager',
type: 'rover.unlocked', type: 'rover.unlocked',
payload: { roverId: id }, payload: { roverId: id },
}); });
const isAllUnlocked = Array.from(rovers.values()).every((entry) => !entry.locked);
if (!wasAllUnlocked && isAllUnlocked) {
publishEvent({
source: 'roverManager',
type: 'rovers.allUnlocked',
payload: { roverId: id },
});
}
} }
broadcastRoster(); broadcastRoster();
managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason }); managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason });
@@ -119,6 +144,7 @@ function getRoster() {
media: record.meta?.media, media: record.meta?.media,
cameraServo: record.meta?.cameraServo, cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio, audio: record.meta?.audio,
nightVision: record.meta?.nightVision,
locked: record.locked, locked: record.locked,
lockReason: record.lockReason, lockReason: record.lockReason,
lastSeen: record.lastSeen, lastSeen: record.lastSeen,
@@ -169,12 +195,218 @@ function handleSensorFrame(roverId, frame) {
const decoded = parseSensorFrame(frame.data); const decoded = parseSensorFrame(frame.data);
record.lastSensor = { raw: frame, decoded }; record.lastSensor = { raw: frame, decoded };
record.batteryState = computeBatteryState(record, decoded); record.batteryState = computeBatteryState(record, decoded);
io.to(record.room).emit('sensorFrame', { updateMovement(record, decoded);
const hasDockInfo = decoded?.chargingSources != null;
if (hasDockInfo) {
const prevDocked = record.docked;
const docked = Boolean(decoded?.chargingSources?.homeBase);
record.docked = docked;
if (prevDocked === true && docked === false) {
handleIdleUndock(record);
}
}
const bumps = decoded?.bumpsAndWheelDrops;
if (bumps?.bumpLeft || bumps?.bumpRight) {
record.lastBumpAt = Date.now();
}
io.to(record.room).volatile.emit('sensorFrame', {
roverId, roverId,
frame, frame,
sensors: decoded, sensors: decoded,
}); });
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState }); managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
evaluateDockGuard(record, decoded);
}
function updateMovement(record, sensors) {
if (!record || !sensors) return;
const distance = Math.abs(sensors.distanceMm ?? 0);
const angle = Math.abs(sensors.angleDeg ?? 0);
const requested = Math.abs(sensors.requestedVelocity ?? 0);
const requestedLeft = Math.abs(sensors.requestedLeftVelocity ?? 0);
const requestedRight = Math.abs(sensors.requestedRightVelocity ?? 0);
const moving = distance > 0 || angle > 0 || requested > 0 || requestedLeft > 0 || requestedRight > 0;
if (moving) {
record.lastMovementAt = Date.now();
}
}
function getDockGuardState(roverId) {
if (!dockGuardStates.has(roverId)) {
dockGuardStates.set(roverId, {
idleUndockedSince: null,
passiveUndockedSince: null,
active: false,
reason: null,
startedAt: null,
timer: null,
});
}
return dockGuardStates.get(roverId);
}
function evaluateDockGuard(record, sensors) {
if (!record || !sensors) return;
const state = getDockGuardState(record.id);
const now = Date.now();
const docked = Boolean(sensors?.chargingSources?.homeBase);
const oiMode = sensors?.oiMode?.label || null;
const idleMs = record.lastMovementAt ? now - record.lastMovementAt : 0;
const isIdle = idleMs >= 1000;
if (docked || record.drivers.size > 0 || !isIdle) {
state.idleUndockedSince = null;
} else if (!state.idleUndockedSince) {
state.idleUndockedSince = now;
}
if (docked || oiMode !== 'passive' || !isIdle) {
state.passiveUndockedSince = null;
} else if (!state.passiveUndockedSince) {
state.passiveUndockedSince = now;
}
if (state.active) {
const shouldStop =
docked ||
!isIdle ||
(state.reason === 'idle' && record.drivers.size > 0) ||
(state.reason === 'passive' && oiMode !== 'passive');
if (shouldStop) {
stopDockGuard(record.id);
}
return;
}
const idleReady =
state.idleUndockedSince && now - state.idleUndockedSince >= IDLE_UNDOCKED_MS;
const passiveReady =
state.passiveUndockedSince && now - state.passiveUndockedSince >= PASSIVE_UNDOCKED_MS;
if (passiveReady) {
startDockGuard(record, 'passive', idleMs);
} else if (idleReady) {
startDockGuard(record, 'idle', idleMs);
}
}
function startDockGuard(record, reason, idleMs) {
if (!record) return;
const state = getDockGuardState(record.id);
if (state.active) return;
state.active = true;
state.reason = reason;
state.startedAt = Date.now();
const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked';
sendAlert({
color: ALERT_COLOR,
title: 'Dock Guard Triggered',
message: `${record.id} ${reasonText}. Seeking dock and restarting sensors until movement.`,
});
publishEvent({
source: 'roverManager',
type: 'rover.dockGuard',
payload: {
roverId: record.id,
reason,
reasonText,
idleMs,
},
});
attemptDockGuard(record.id);
state.timer = setInterval(() => attemptDockGuard(record.id), DOCK_GUARD_RETRY_MS);
}
function stopDockGuard(roverId) {
const state = dockGuardStates.get(roverId);
if (!state) return;
if (state.timer) {
clearInterval(state.timer);
}
state.active = false;
state.reason = null;
state.startedAt = null;
state.timer = null;
state.idleUndockedSince = null;
state.passiveUndockedSince = null;
}
function attemptDockGuard(roverId) {
const record = rovers.get(roverId);
if (!record) {
stopDockGuard(roverId);
return;
}
const { issueCommand } = require('./commandService');
try {
issueCommand(roverId, { type: 'sensorStream', sensorStream: { enable: true } });
issueCommand(roverId, { type: 'raw', raw: DOCK_COMMAND_BASE64 });
} catch (err) {
logger.warn('Dock guard command failed', roverId, err.message);
}
}
function handleIdleUndock(undockedRecord) {
if (!undockedRecord || undockedRecord.drivers.size > 0) return;
const now = Date.now();
const { getRecentDriveActivity, setDriveCooldown, issueCommand } = require('./commandService');
const candidates = getRecentDriveActivity(DOCK_GUARD_WINDOW_MS, { excludeAdmins: true })
.filter((candidate) => candidate.roverId !== undockedRecord.id);
if (candidates.length === 0) return;
const candidatesWithBump = candidates.filter((candidate) => {
const record = rovers.get(candidate.roverId);
return record?.lastBumpAt && now - record.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
});
const pool = candidatesWithBump.length > 0 ? candidatesWithBump : candidates;
pool.sort((a, b) => b.ts - a.ts);
const suspect = pool[0];
if (!suspect) return;
const suspectRecord = rovers.get(suspect.roverId);
if (!suspectRecord) return;
const bumpRecent =
suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
sendAlert({
color: ALERT_COLOR,
title: 'Dock protection',
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}.`,
});
try {
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
issueCommand(suspect.roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
} catch (err) {
logger.warn('Dock protection stop failed', suspect.roverId, err.message);
}
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
if (bumpRecent) {
nudgeRover(suspect.roverId, 'backward');
} else {
nudgeRover(suspect.roverId, 'forward');
}
}
function nudgeRover(roverId, direction = 'backward') {
if (!roverId) return;
const { issueCommand } = require('./commandService');
clearTimeout(backoffTimers.get(roverId));
const speed = direction === 'forward' ? BACKOFF_SPEED : -BACKOFF_SPEED;
try {
issueCommand(roverId, {
type: 'drive',
driveDirect: { left: speed, right: speed },
});
} catch (err) {
logger.warn('Dock protection nudge failed', roverId, err.message);
return;
}
backoffTimers.set(
roverId,
setTimeout(() => {
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
} catch (err) {
logger.warn('Dock protection backoff stop failed', roverId, err.message);
}
}, BACKOFF_MS),
);
} }
function removeSocket(socket) { function removeSocket(socket) {
@@ -204,7 +436,7 @@ function requestControl(roverId, socket, options = {}) {
if (!allowUser && !isAdmin(socket)) { if (!allowUser && !isAdmin(socket)) {
throw new Error('Only admins can request control'); throw new Error('Only admins can request control');
} }
if (record.locked && !isAdmin(socket) && !allowUser) { if (record.locked && !isAdmin(socket)) {
throw new Error('Rover locked'); throw new Error('Rover locked');
} }
const mode = getMode(); const mode = getMode();
@@ -224,7 +456,7 @@ function requestControl(roverId, socket, options = {}) {
socket.emit('controlGranted', { roverId }); socket.emit('controlGranted', { roverId });
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' }); managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
sendAlert({ sendAlert({
color: COLORS.success, color: ALERT_COLOR,
title: 'Control Granted', title: 'Control Granted',
message: `${socket.id} now driving ${roverId}`, message: `${socket.id} now driving ${roverId}`,
}); });
@@ -254,7 +486,13 @@ function isDriver(roverId, socket) {
} }
function canDrive(roverId, socket) { function canDrive(roverId, socket) {
return turnService.canDrive(roverId, socket) || isAdmin(socket); if (isAdmin(socket)) {
return true;
}
if (!socket || !isDriver(roverId, socket)) {
return false;
}
return turnService.canDrive(roverId, socket);
} }
function getRoversForSocket(socketId) { function getRoversForSocket(socketId) {
@@ -275,6 +513,41 @@ function getPrimaryRoverForSocket(socketId) {
return first.done ? null : first.value; return first.done ? null : first.value;
} }
function isDockedAndCharging(record) {
const sensors = record?.lastSensor?.decoded || record?.lastSensor?.sensors || null;
if (!sensors) return false;
const docked = Boolean(sensors.chargingSources?.homeBase);
const code = sensors.chargingState?.code;
const charging = code === 2 || code === 3 || code === 4;
return docked && charging;
}
function hasOtherDrivers(record, socketId) {
if (!record) return false;
for (const driver of record.drivers) {
if (driver !== socketId) return true;
}
return false;
}
function canSwitchRover(socket, targetRoverId) {
const currentId = getPrimaryRoverForSocket(socket.id);
if (!currentId || currentId === targetRoverId) {
return { ok: true, currentId };
}
const currentRecord = rovers.get(currentId);
if (!currentRecord) {
return { ok: true, currentId };
}
if (hasOtherDrivers(currentRecord, socket.id)) {
return { ok: true, currentId };
}
if (isDockedAndCharging(currentRecord)) {
return { ok: true, currentId };
}
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
}
module.exports = { module.exports = {
upsertRover, upsertRover,
removeRover, removeRover,
@@ -304,24 +577,53 @@ roleEvents.on('change', ({ socket, role }) => {
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.emit('rovers', getRoster()); if (!isBannedSocket(socket)) {
if (socket.data?.role === 'spectator') { socket.emit('rovers', getRoster());
enableSpectator(socket); if (socket.data?.role === 'spectator') {
enableSpectator(socket);
}
} }
function handleRequestControl({ roverId, force } = {}, cb = () => {}) { function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
try { try {
if (socket.data?.role === 'spectator') {
throw new Error('Spectators cannot drive');
}
if ((getMode() === MODES.ADMIN || getMode() === MODES.LOCKDOWN) && !isAdmin(socket)) {
throw new Error('Admins only');
}
const targetId = roverId || Array.from(rovers.keys())[0]; const targetId = roverId || Array.from(rovers.keys())[0];
if (!targetId) { if (!targetId) {
throw new Error('No rovers available'); throw new Error('No rovers available');
} }
logger.info('Request control', socket.id, targetId, { force }); const previousJoined = getRoversForSocket(socket.id);
requestControl(targetId, socket, { force: Boolean(force) }); if (!isAdmin(socket)) {
const { ok, message } = canSwitchRover(socket, targetId);
if (!ok) {
throw new Error(message || 'Switch denied');
}
}
const forceAllowed = Boolean(force) && isAdmin(socket);
logger.info('Request control', socket.id, targetId, { force: forceAllowed });
requestControl(targetId, socket, { force: forceAllowed, allowUser: true });
previousJoined.forEach((rid) => {
if (rid !== targetId) {
releaseControl(rid, socket);
}
});
videoSessions.revokeWhere(
(info) =>
info.socketId === socket.id &&
info.sourceType === 'rover' &&
info.sourceId !== targetId &&
info.sourceId !== `${targetId}-audio`,
);
managerEvents.emit('switch', { socketId: socket.id, roverId: targetId });
socket.emit('controlGranted', { roverId: targetId }); socket.emit('controlGranted', { roverId: targetId });
cb({ success: true, roverId: targetId }); cb({ success: true, roverId: targetId });
} catch (err) { } catch (err) {
logger.warn('Request control failed', socket.id, err.message); logger.warn('Request control failed', socket.id, err.message);
sendAlert({ color: COLORS.warn, title: 'Control denied', message: err.message }); sendAlert({ color: ALERT_COLOR, title: 'Control denied', message: err.message });
cb({ error: err.message }); cb({ error: err.message });
} }
} }
@@ -347,7 +649,7 @@ io.on('connection', (socket) => {
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
logger.warn('Lock change failed', roverId, err.message); logger.warn('Lock change failed', roverId, err.message);
sendAlert({ color: COLORS.error, title: 'Lock failed', message: err.message }); sendAlert({ color: ALERT_COLOR, title: 'Lock failed', message: err.message });
cb({ error: err.message }); cb({ error: err.message });
} }
} }
@@ -377,6 +679,10 @@ io.on('connection', (socket) => {
socket.on('subscribeAll', handleSubscribeAll); socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll); socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => {
logger.info('Socket disconnecting', socket.id);
removeSocket(socket);
});
socket.on('disconnect', () => { socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id); logger.info('Socket disconnected', socket.id);
removeSocket(socket); removeSocket(socket);
@@ -0,0 +1,94 @@
const EventEmitter = require('events');
const fs = require('fs/promises');
const path = require('path');
const logger = require('../globals/logger').child('roverSnapshot');
const roverManager = require('./roverManager');
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const POLL_INTERVAL_MS = 300;
const roverState = new Map(); // id -> { frame, ts, error, failures, fetching, mtimeMs }
const events = new EventEmitter(); // frame, status
let pollTimer = null;
function markState(id, updates = {}) {
const prev = roverState.get(id) || {};
const next = { ...prev, ...updates };
roverState.set(id, next);
return next;
}
function getSnapshotPath(id) {
return path.join(SNAPSHOT_DIR, `${id}.jpg`);
}
async function fetchSnapshot(id) {
const state = roverState.get(id);
if (state?.fetching) return;
markState(id, { fetching: true });
try {
const filePath = getSnapshotPath(id);
const stats = await fs.stat(filePath);
if (state?.mtimeMs && stats.mtimeMs <= state.mtimeMs) {
return;
}
const buffer = await fs.readFile(filePath);
const ts = stats.mtimeMs || Date.now();
markState(id, { frame: buffer, ts, error: null, failures: 0, mtimeMs: stats.mtimeMs });
events.emit('frame', { id, buffer, ts });
} catch (err) {
const failures = (state?.failures || 0) + 1;
const message = err.code === 'ENOENT' ? 'Snapshot missing' : err.message;
markState(id, { error: message, failures });
events.emit('status', { id, error: message });
if (failures % 20 === 1) {
logger.warn('Snapshot read failed', { id, err: message });
}
} finally {
markState(id, { fetching: false });
}
}
function cleanupInactive(activeIds) {
roverState.forEach((_, id) => {
if (!activeIds.has(id)) {
roverState.delete(id);
}
});
}
function stopAll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
roverState.clear();
}
function startAll() {
stopAll();
pollTimer = setInterval(() => {
const roster = roverManager.getRoster();
const activeIds = new Set(roster.map((entry) => String(entry.id)));
cleanupInactive(activeIds);
roster.forEach((entry) => fetchSnapshot(String(entry.id)));
}, POLL_INTERVAL_MS);
logger.info('Started rover snapshot polling');
}
function getState(id) {
const state = roverState.get(id);
if (!state) return null;
return {
frame: state.frame || null,
ts: state.ts || null,
error: state.error || null,
};
}
startAll();
module.exports = {
roverSnapshotEvents: events,
getRoverSnapshotState: getState,
};
@@ -0,0 +1,168 @@
const io = require('../globals/io');
const logger = require('../globals/logger').child('roverSnapshotSocket');
const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const roverManager = require('./roverManager');
const { roverSnapshotEvents, getRoverSnapshotState } = require('./roverSnapshotService');
const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 333;
function passesMode(socket) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
return isLockdownAdmin(socket);
}
if (mode === MODES.ADMIN) {
const role = getRole(socket);
return role === 'spectator' || isAdmin(socket);
}
return true;
}
function canViewSnapshots(socket) {
return passesMode(socket);
}
const roverSubscribers = new Map(); // id -> Set(socketId)
const socketSubscriptions = new Map(); // socketId -> Set(id)
const subscribeBuckets = new Map(); // socketId -> { start, count }
const lastSentBySocket = new Map(); // socketId -> Map(roverId -> ts)
function addSubscription(socket, roverId) {
if (!roverSubscribers.has(roverId)) {
roverSubscribers.set(roverId, new Set());
}
roverSubscribers.get(roverId).add(socket.id);
if (!socketSubscriptions.has(socket.id)) {
socketSubscriptions.set(socket.id, new Set());
}
socketSubscriptions.get(socket.id).add(roverId);
}
function removeSubscription(socketId, roverId) {
const bucket = roverSubscribers.get(roverId);
if (bucket) {
bucket.delete(socketId);
if (bucket.size === 0) {
roverSubscribers.delete(roverId);
}
}
const socketBucket = socketSubscriptions.get(socketId);
if (socketBucket) {
socketBucket.delete(roverId);
if (socketBucket.size === 0) {
socketSubscriptions.delete(socketId);
}
}
}
function removeAllSubscriptions(socketId) {
const bucket = socketSubscriptions.get(socketId);
if (!bucket) return;
bucket.forEach((roverId) => removeSubscription(socketId, roverId));
}
function allowSubscribe(socketId) {
const now = Date.now();
let bucket = subscribeBuckets.get(socketId);
if (!bucket || now - bucket.start >= SUBSCRIBE_WINDOW_MS) {
bucket = { start: now, count: 0 };
}
bucket.count += 1;
subscribeBuckets.set(socketId, bucket);
return bucket.count <= SUBSCRIBE_LIMIT;
}
function sendFrame(socket, roverId, payload, buffer) {
socket.emit('roverSnapshot:frame', { id: roverId, ...payload }, buffer);
}
function sendStatus(socket, roverId, status) {
socket.emit('roverSnapshot:status', { id: roverId, ...status });
}
roverSnapshotEvents.on('frame', ({ id, buffer, ts }) => {
const bucket = roverSubscribers.get(id);
if (!bucket || !buffer) return;
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
let lastMap = lastSentBySocket.get(socketId);
if (!lastMap) {
lastMap = new Map();
lastSentBySocket.set(socketId, lastMap);
}
const lastSent = lastMap.get(id) || 0;
const now = ts || Date.now();
if (now - lastSent < STREAM_INTERVAL_MS) {
return;
}
lastMap.set(id, now);
sendFrame(socket, id, { ts }, buffer);
});
});
roverSnapshotEvents.on('status', ({ id, error }) => {
const bucket = roverSubscribers.get(id);
if (!bucket) return;
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
sendStatus(socket, id, { error: error || null });
});
});
io.on('connection', (socket) => {
socket.on('roverSnapshot:subscribe', (payload = {}, cb = () => {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roverId || payload?.id
? [String(payload.roverId || payload.id)]
: roverManager.getRoster().map((rover) => rover.id);
const uniqueIds = Array.from(new Set(list));
try {
if (!allowSubscribe(socket.id)) {
cb({ error: 'Rate limited' });
return;
}
if (!canViewSnapshots(socket)) {
throw new Error('Not authorized for rover snapshots');
}
const rosterIds = new Set(roverManager.getRoster().map((entry) => String(entry.id)));
const validIds = uniqueIds.filter((id) => rosterIds.has(String(id)));
validIds.forEach((roverId) => addSubscription(socket, roverId));
validIds.forEach((roverId) => {
const state = getRoverSnapshotState(roverId);
if (state?.frame) {
sendFrame(socket, roverId, { ts: state.ts }, state.frame);
}
sendStatus(socket, roverId, {
ts: state?.ts || null,
error: state?.error || null,
});
});
cb({ ok: true, subscribed: validIds });
} catch (err) {
logger.warn('Rover snapshot subscribe failed', { socketId: socket.id, err: err.message });
cb({ error: err.message });
}
});
socket.on('roverSnapshot:unsubscribe', (payload = {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roverId || payload?.id
? [String(payload.roverId || payload.id)]
: [];
list.forEach((roverId) => removeSubscription(socket.id, roverId));
});
socket.on('disconnect', () => {
removeAllSubscriptions(socket.id);
subscribeBuckets.delete(socket.id);
lastSentBySocket.delete(socket.id);
});
});
+26
View File
@@ -9,10 +9,18 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
const { getNickname, nicknameEvents } = require('./nicknameService'); const { getNickname, nicknameEvents } = require('./nicknameService');
const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { getCommunityGoal } = require('./communityGoalService');
const { subscribe } = require('./eventBus');
const { isBannedSocket } = require('./moderationService');
const discordInvite = loadConfig().discord?.invite || null; const discordInvite = loadConfig().discord?.invite || null;
const kofiLink = loadConfig().kofi?.link || null;
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured'); logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
const ACTIVITY_SYNC_COOLDOWN_MS = 3000; const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
let lastActivitySync = 0; let lastActivitySync = 0;
@@ -45,15 +53,23 @@ function buildSession(socket) {
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
roomCameras: getRoomCameras(), roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(), homeAssistant: getHomeAssistantState(),
replay: getReplayState(),
replaySources: getReplaySources(),
health: getHealthSnapshot(),
communityGoal: getCommunityGoal(),
users, users,
discord: { discord: {
invite: discordInvite, invite: discordInvite,
}, },
kofi: {
link: kofiLink,
},
}; };
} }
function syncSocket(socket) { function syncSocket(socket) {
if (!socket) return; if (!socket) return;
if (isBannedSocket(socket)) return;
const payload = buildSession(socket); const payload = buildSession(socket);
logger.info('Syncing session', socket.id, payload.role, payload.assignment); logger.info('Syncing session', socket.id, payload.role, payload.assignment);
socket.emit('session:sync', payload); socket.emit('session:sync', payload);
@@ -156,6 +172,11 @@ homeAssistantEvents.on('status', () => {
syncAll(); syncAll();
}); });
replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients');
syncAll();
});
nicknameEvents.on('change', ({ socketId }) => { nicknameEvents.on('change', ({ socketId }) => {
const socket = socketId ? io.sockets.sockets.get(socketId) : null; const socket = socketId ? io.sockets.sockets.get(socketId) : null;
if (socket) { if (socket) {
@@ -166,6 +187,11 @@ nicknameEvents.on('change', ({ socketId }) => {
} }
}); });
subscribe('communityGoal.updated', () => {
logger.info('Community goal updated; syncing all clients');
syncAll();
});
// sync all sockets 20 seconds // sync all sockets 20 seconds
setInterval(() => { setInterval(() => {
logger.info('Periodic session sync for all clients'); logger.info('Periodic session sync for all clients');
+37 -5
View File
@@ -1,6 +1,7 @@
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 = '#ff5722';
const { MODES, getMode, modeEvents } = require('./modeManager'); const { MODES, getMode, modeEvents } = require('./modeManager');
const driverQueues = new Map(); // roverId -> { queue: [], current: socketId, timer: Timeout | null } const driverQueues = new Map(); // roverId -> { queue: [], current: socketId, timer: Timeout | null }
@@ -8,6 +9,7 @@ const activeDrivers = new Map();
const TURN_DURATION_MS = 60 * 1000; const TURN_DURATION_MS = 60 * 1000;
const IDLE_TIMEOUT_MS = 7 * 1000; const IDLE_TIMEOUT_MS = 7 * 1000;
const MAX_IDLE_SKIPS = 3; const MAX_IDLE_SKIPS = 3;
const STALE_REAPER_MS = 5000;
const turnEvents = new EventEmitter(); const turnEvents = new EventEmitter();
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
@@ -39,6 +41,7 @@ function driverRemoved(roverId, socketId) {
} }
idleDisarmed.delete(roverId); idleDisarmed.delete(roverId);
if (queue.current === socketId) { if (queue.current === socketId) {
stopRover(roverId);
advanceTurn(roverId); advanceTurn(roverId);
} else { } else {
scheduleIdleTimer(roverId); scheduleIdleTimer(roverId);
@@ -158,7 +161,7 @@ function handleIdleTimeout(roverId, expectedDriver) {
stopRover(roverId); stopRover(roverId);
if (skips >= MAX_IDLE_SKIPS) { if (skips >= MAX_IDLE_SKIPS) {
sendAlert({ sendAlert({
color: COLORS.error, color: ALERT_COLOR,
title: 'Driver removed', title: 'Driver removed',
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`, message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
}); });
@@ -166,7 +169,7 @@ function handleIdleTimeout(roverId, expectedDriver) {
return; return;
} }
sendAlert({ sendAlert({
color: COLORS.warn, color: ALERT_COLOR,
title: 'Turn skipped', title: 'Turn skipped',
message: `${expectedDriver} skipped on ${roverId} (idle ${skips}/${MAX_IDLE_SKIPS})`, message: `${expectedDriver} skipped on ${roverId} (idle ${skips}/${MAX_IDLE_SKIPS})`,
}); });
@@ -177,6 +180,7 @@ function advanceTurn(roverId) {
const queue = driverQueues.get(roverId); const queue = driverQueues.get(roverId);
if (!queue) return; if (!queue) return;
if (queue.queue.length === 0) { if (queue.queue.length === 0) {
stopRover(roverId);
clearTimeout(queue.timer); clearTimeout(queue.timer);
setActiveDriver(roverId, null); setActiveDriver(roverId, null);
turnDeadlines.delete(roverId); turnDeadlines.delete(roverId);
@@ -205,7 +209,7 @@ function advanceTurn(roverId) {
queue.current = queue.queue[nextIdx]; queue.current = queue.queue[nextIdx];
setActiveDriver(roverId, queue.current); setActiveDriver(roverId, queue.current);
idleDisarmed.set(roverId, false); idleDisarmed.set(roverId, false);
sendAlert({ color: COLORS.info, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` }); sendAlert({ color: ALERT_COLOR, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` });
stopRover(roverId); stopRover(roverId);
scheduleNextTurn(roverId); scheduleNextTurn(roverId);
scheduleIdleTimer(roverId); scheduleIdleTimer(roverId);
@@ -265,16 +269,44 @@ function removeDriverCompletely(roverId, socketId) {
function recordActivity(roverId, socketId) { function recordActivity(roverId, socketId) {
const queue = driverQueues.get(roverId); const queue = driverQueues.get(roverId);
if (!queue || queue.current !== socketId) return; if (!queue || queue.current !== socketId) return;
if (idleDisarmed.get(roverId)) return;
idleDisarmed.set(roverId, true); idleDisarmed.set(roverId, true);
const hadDeadline = idleDeadlines.has(roverId);
clearTimeout(idleTimers.get(roverId)); clearTimeout(idleTimers.get(roverId));
idleDeadlines.delete(roverId); idleDeadlines.delete(roverId);
turnEvents.emit('queue', { roverId, reason: 'activity' }); if (hadDeadline) {
turnEvents.emit('queue', { roverId, reason: 'activity' });
}
} }
modeEvents.on('change', (mode) => { modeEvents.on('change', (mode) => {
driverQueues.forEach((_, roverId) => syncState(roverId)); driverQueues.forEach((_, roverId) => syncState(roverId));
}); });
function reapStaleDrivers() {
const staleIds = new Set();
driverQueues.forEach((queue) => {
queue.queue.forEach((socketId) => {
if (!io.sockets.sockets.has(socketId)) {
staleIds.add(socketId);
}
});
if (queue.current && !io.sockets.sockets.has(queue.current)) {
staleIds.add(queue.current);
}
});
if (staleIds.size === 0) return;
driverQueues.forEach((queue, roverId) => {
staleIds.forEach((socketId) => {
if (queue.current === socketId || queue.queue.includes(socketId)) {
driverRemoved(roverId, socketId);
}
});
});
}
setInterval(reapStaleDrivers, STALE_REAPER_MS);
module.exports = { module.exports = {
driverAdded, driverAdded,
driverRemoved, driverRemoved,
+22 -1
View File
@@ -6,6 +6,9 @@ const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService'); const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { getRequestIp } = require('../helpers/ipResolver');
const { logAdminEvent } = require('./adminLogService');
const { isBannedSocket } = require('./moderationService');
const config = loadConfig(); const config = loadConfig();
const mediaConfig = config.media || {}; const mediaConfig = config.media || {};
@@ -74,9 +77,24 @@ app.post('/mediamtx/auth', (req, res) => {
const body = req.body || {}; const body = req.body || {};
const path = (body.path || '').replace(/^\//, ''); const path = (body.path || '').replace(/^\//, '');
const sessionId = body.user; const sessionId = body.user;
const action = (body.action || '').toLowerCase();
const protocol = (body.protocol || '').toLowerCase();
const ip = getRequestIp(req, body.ip);
const streamInfo = extractStreamInfo(path); const streamInfo = extractStreamInfo(path);
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo }); logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol });
if (ip) {
logAdminEvent({
label: 'mediamtx',
message: 'Media auth request',
ip,
meta: { path: body.path, sessionId, stream: streamInfo, action, protocol },
});
}
if (action === 'read' && protocol === 'srt' && streamInfo?.id) {
return res.status(200).end();
}
if (!sessionId || !streamInfo?.id) { if (!sessionId || !streamInfo?.id) {
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path); logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
@@ -93,6 +111,9 @@ app.post('/mediamtx/auth', (req, res) => {
videoSessions.revokeSession(sessionId); videoSessions.revokeSession(sessionId);
return res.status(401).end(); return res.status(401).end();
} }
if (isBannedSocket(socket)) {
return res.status(401).end();
}
if (!canView(socket)) { if (!canView(socket)) {
return res.status(401).end(); return res.status(401).end();
} }
@@ -5,6 +5,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const videoSessions = require('./videoSessions'); const videoSessions = require('./videoSessions');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { isBannedSocket } = require('./moderationService');
const config = loadConfig(); const config = loadConfig();
const mediaConfig = config.media || {}; const mediaConfig = config.media || {};
@@ -80,6 +81,9 @@ function normalizeRequest(payload = {}) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('video:request', (payload = {}, cb = () => {}) => { socket.on('video:request', (payload = {}, cb = () => {}) => {
try { try {
if (isBannedSocket(socket)) {
throw new Error('Banned');
}
const target = normalizeRequest(payload); const target = normalizeRequest(payload);
if (!target) { if (!target) {
throw new Error('video source required'); throw new Error('video source required');
+1 -1
View File
@@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist # dist
dist-ssr dist-ssr
*.local *.local
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-APFDmmUd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DoNH_msu.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+9
View File
@@ -33,6 +33,15 @@
} }
} }
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.card { .card {
padding: 2em; padding: 2em;
} }
+163 -69
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import TelemetryPanel from './components/TelemetryPanel.jsx'; import TelemetryPanel from './components/TelemetryPanel.jsx';
import DrivePanel from './components/DrivePanel.jsx'; import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
import AlertFeed from './components/AlertFeed.jsx'; import AlertFeed from './components/AlertFeed.jsx';
import AdminPanel from './components/AdminPanel.jsx';
import MobileControls, { import MobileControls, {
MobileLandscapeAuxColumn, MobileLandscapeAuxColumn,
MobileLandscapeControlColumn, MobileLandscapeControlColumn,
@@ -11,17 +10,24 @@ import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from
import { SettingsProvider } from './settings/index.js'; import { SettingsProvider } from './settings/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx'; import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx'; import LogPanel from './components/LogPanel.jsx';
import AuthPanel from './components/AuthPanel.jsx';
import DriverVideoPanel from './components/DriverVideoPanel.jsx'; import DriverVideoPanel from './components/DriverVideoPanel.jsx';
import RightPaneTabs from './components/RightPaneTabs.jsx'; import RightPaneTabs from './components/RightPaneTabs.jsx';
import ModeGateOverlay from './components/ModeGateOverlay.jsx'; import ModeGateOverlay from './components/ModeGateOverlay.jsx';
import SessionSnapshot from './components/SessionSnapshot.jsx';
import HomeAssistantControls from './components/HomeAssistantControls.jsx'; import HomeAssistantControls from './components/HomeAssistantControls.jsx';
import TurnAlertListener from './components/TurnAlertListener.jsx'; import TurnAlertListener from './components/TurnAlertListener.jsx';
import UserListPanel from './components/UserListPanel.jsx'; import RawUserPilePanel from './components/RawUserPilePanel.jsx';
import ChatPanel from './components/ChatPanel.jsx'; import ChatPanel from './components/ChatPanel.jsx';
import FullscreenPrompt from './components/FullscreenPrompt.jsx'; import FullscreenPrompt from './components/FullscreenPrompt.jsx';
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js'; import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
import { useSettingsNamespace } from './settings/index.js';
import HelpOverlay from './components/HelpOverlay.jsx';
import HelpPanel from './components/HelpPanel.jsx';
import SettingsPanel from './components/SettingsPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
import useDefaultNickname from './hooks/useDefaultNickname.js';
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
import BannedOverlay from './components/BannedOverlay.jsx';
function useLayoutMode() { function useLayoutMode() {
const [mode, setMode] = useState(() => { const [mode, setMode] = useState(() => {
@@ -53,73 +59,108 @@ function useLayoutMode() {
return mode; return mode;
} }
function DesktopLayout({ layout }) { function DesktopLayout({ layout, onOpenHelpOverlay }) {
return ( return (
<div className="flex h-full gap-0.5 overflow-hidden"> <div className="flex h-full gap-0.5 overflow-hidden">
<div className="flex min-w-0 flex-[1.8] flex-col gap-0.5 overflow-y-auto pr-0.5"> <div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
<DriverVideoPanel /> <DriverVideoPanel />
<div className="grid h-52 grid-cols-2 gap-0.5"> <TelemetryPanel />
<div className="h-full min-h-0">
<UserListPanel fillHeight />
</div>
<div className="h-full min-h-0">
<ChatPanel fillHeight />
</div>
</div>
<LogPanel /> <LogPanel />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto"> <div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
<RightPaneTabs layout={layout} /> <CommunityGoalBanner layout={layout} />
<RightPaneTabs layout={layout} onOpenHelpOverlay={onOpenHelpOverlay} />
{/* <SessionSnapshot /> */} {/* <SessionSnapshot /> */}
</div> </div>
</div> </div>
); );
} }
function MobilePortraitLayout() { function MobileFeatureTabs({
layout,
onOpenHelpOverlay,
roomPanelId,
showTelemetry = true,
}) {
return (
<section className="panel text-base">
<Tabs defaultTab="chat">
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="roomcontrols">Room Controls</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
<TabPanels>
<TabPanel id="chat">
<div className="space-y-0.5">
<ChatPanel />
<RawUserPilePanel />
</div>
</TabPanel>
<TabPanel id="roomcontrols">
<div className="space-y-0.5">
{/* {showTelemetry ? <TelemetryPanel /> : null} */}
<HomeAssistantControls />
<RoomCameraPanel panelId={roomPanelId} />
</div>
</TabPanel>
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
</TabPanel>
<TabPanel id="settings">
<div className="space-y-0.5">
<SettingsPanel />
<LogPanel />
</div>
</TabPanel>
</TabPanels>
</Tabs>
</section>
);
}
function MobilePortraitLayout({ onOpenHelpOverlay }) {
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<DriverVideoPanel layoutFormat='mobile'/> <DriverVideoPanel layoutFormat="mobile-portrait" />
<MobileControls /> <MobileControls />
<ChatPanel /> <div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<UserListPanel /> <ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
<DrivePanel /> <RoverQueuesPanel />
<TelemetryPanel /> </div>
<AuthPanel /> {/* <ControlSummary /> */}
<AdminPanel /> <MobileFeatureTabs
<RoomCameraPanel panelId="mobile-portrait-room" /> layout="mobile-portrait"
<HomeAssistantControls /> onOpenHelpOverlay={onOpenHelpOverlay}
<LogPanel /> roomPanelId="mobile-portrait-room"
/>
</div> </div>
); );
} }
function MobileLandscapeLayout() { function MobileLandscapeLayout({ onOpenHelpOverlay }) {
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5"> <section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
<MobileLandscapeAuxColumn /> <MobileLandscapeAuxColumn />
<div> <div>
<DriverVideoPanel layoutFormat='mobile'/> <DriverVideoPanel layoutFormat="mobile-landscape" />
<TelemetryPanel /> <div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
<RoverQueuesPanel />
</div>
{/* <TelemetryPanel /> */}
</div> </div>
<MobileLandscapeControlColumn /> <MobileLandscapeControlColumn />
</section> </section>
<div className="flex flex-col gap-0.5 pb-0.5"> <div className="flex flex-col gap-0.5 pb-0">
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5"> <MobileFeatureTabs
<div className="flex flex-col gap-0.5"> layout="mobile-landscape"
{/* <AuthPanel /> */} onOpenHelpOverlay={onOpenHelpOverlay}
{/* <AdminPanel /> */} roomPanelId="mobile-landscape-room"
<UserListPanel /> showTelemetry={false}
</div> />
<div className="flex flex-col gap-0.5">
{/* <LogPanel /> */}
<ChatPanel />
</div>
</section>
<RoomCameraPanel panelId="mobile-landscape-room" />
<HomeAssistantControls />
{/* <DrivePanel /> */}
</div> </div>
</div> </div>
); );
@@ -128,36 +169,89 @@ function MobileLandscapeLayout() {
function App() { function App() {
const layout = useLayoutMode(); const layout = useLayoutMode();
const isDesktop = layout === 'desktop'; const isDesktop = layout === 'desktop';
const { visible: fullscreenVisible, mode: fullscreenMode, enterFullscreen, dismiss } = useFullscreenPrompt(layout); const fullscreen = useFullscreenPrompt(layout);
const renderedLayout =
isDesktop
? <DesktopLayout layout={layout} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout />
: <MobilePortraitLayout />;
return ( return (
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}> <div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
<SettingsProvider> <SettingsProvider>
<ControlSystemProvider> <AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
<KeyboardInputManager />
<GamepadInputManager />
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
{renderedLayout}
</main>
<AlertFeed />
<TurnAlertListener />
<ModeGateOverlay />
<FullscreenPrompt
visible={fullscreenVisible}
mode={fullscreenMode}
onEnterFullscreen={enterFullscreen}
onDismiss={dismiss}
/>
</ControlSystemProvider>
</SettingsProvider> </SettingsProvider>
</div> </div>
); );
} }
function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname();
const {
visible: fullscreenVisible,
mode: fullscreenMode,
enterFullscreen,
dismiss,
} = fullscreen;
const {
value: helpSettings,
status: helpStatus,
save: saveHelpSettings,
} = useSettingsNamespace('help', { showOnLoad: true });
const [helpVisible, setHelpVisible] = useState(false);
useEffect(() => {
if (helpStatus === 'ready') {
setHelpVisible(helpSettings?.showOnLoad !== false);
}
}, [helpStatus, helpSettings?.showOnLoad]);
const openHelp = useCallback(() => setHelpVisible(true), []);
const closeHelp = useCallback(() => setHelpVisible(false), []);
const setShowOnLoad = useCallback(
(enabled) => {
const next = Boolean(enabled);
saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
if (!next) {
setHelpVisible(false);
}
},
[saveHelpSettings],
);
const renderedLayout = useMemo(
() =>
isDesktop
? <DesktopLayout layout={layout} onOpenHelpOverlay={openHelp} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout onOpenHelpOverlay={openHelp} />
: <MobilePortraitLayout onOpenHelpOverlay={openHelp} />,
[isDesktop, layout, openHelp],
);
return (
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
{!isDesktop ? <CommunityGoalBanner layout={layout} /> : null}
{renderedLayout}
</main>
<AlertFeed />
<TurnAlertListener />
<ModeGateOverlay />
<BannedOverlay />
<HelpOverlay
visible={helpVisible}
layout={layout}
onClose={closeHelp}
showOnLoad={helpSettings?.showOnLoad !== false}
onToggleShowOnLoad={setShowOnLoad}
/>
<FullscreenPrompt
visible={fullscreenVisible}
mode={fullscreenMode}
onEnterFullscreen={enterFullscreen}
onDismiss={dismiss}
/>
</ControlSystemProvider>
);
}
export default App; export default App;
+107
View File
@@ -0,0 +1,107 @@
aroma
bagel
batter
beans
beer
biscuit
bread
broth
burger
burrito
butter
cake
candy
caramel
caviar
cheese
chili
chimichanga
chocolate
cider
cobbler
cocoa
coffee
cookie
cream
croissant
crumble
cuisine
curd
dessert
dish
drink
eggs
empanada
enchilada
entree
filet
fish
flour
foie gras
food
glaze
grill
hamburger
ice
juice
ketchup
kitchen
lard
liquor
margarine
marinade
mayo
mayonnaise
meat
milk
mousse
muffin
mushroom
noodle
nut
oil
olive
omelette
pan
pasta
paste
pastry
pie
pizza
plate
pot
poutine
pudding
queso
raclette
recipe
rice
salad
salsa
sandwich
sauce
seasoning
skillet
soda
sopapillas
soup
soy
spice
steak
stew
syrup
taco
taquito
tartar
taste
tea
toast
tostada
vinegar
waffle
water
wheat
wine
wok
yeast
yogurt
+349 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import RoverRoster from './RoverRoster.jsx'; import RoverRoster from './RoverRoster.jsx';
@@ -10,9 +10,24 @@ const MODES = [
]; ];
export default function AdminPanel() { export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession(); const {
session,
lockRover,
setMode,
requestControl,
setCommunityGoal,
adminLogs,
moderation,
banUser,
timeoutUser,
unbanUser,
} = useSession();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]); const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({}); const [lockStates, setLockStates] = useState({});
const health = session?.health || null;
const currentGoal = session?.communityGoal?.text || '';
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
const [goalDraft, setGoalDraft] = useState(currentGoal);
const isAdmin = const isAdmin =
session?.role === 'admin' || session?.role === 'admin' ||
@@ -47,6 +62,26 @@ export default function AdminPanel() {
} }
}; };
const handleGoalSave = async () => {
try {
await setCommunityGoal(goalDraft);
} catch (err) {
alert(err.message);
}
};
const handleGoalClear = async () => {
try {
await setCommunityGoal(null);
} catch (err) {
alert(err.message);
}
};
useEffect(() => {
setGoalDraft(currentGoal);
}, [currentGoal]);
const lockMap = useMemo(() => { const lockMap = useMemo(() => {
const map = {}; const map = {};
roster.forEach((rover) => { roster.forEach((rover) => {
@@ -69,6 +104,29 @@ export default function AdminPanel() {
))} ))}
</select> </select>
</div> </div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Community goal</span>
{goalUpdatedAt ? (
<span>Updated {new Date(goalUpdatedAt).toLocaleString()}</span>
) : null}
</div>
<input
type="text"
value={goalDraft}
onChange={(event) => setGoalDraft(event.target.value)}
placeholder="Set a community goal"
className="field-input text-sm"
/>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleGoalSave} className="button-dark">
Set goal
</button>
<button type="button" onClick={handleGoalClear} className="button-danger">
Clear
</button>
</div>
</div>
<RoverRoster <RoverRoster
roster={roster} roster={roster}
@@ -87,6 +145,295 @@ export default function AdminPanel() {
</div> </div>
)} )}
/> />
<ReplaySnapshotHealth health={health} />
<ModerationPanel
moderation={moderation}
onBan={banUser}
onTimeout={timeoutUser}
onUnban={unbanUser}
/>
<AdminIpLogPanel entries={adminLogs} />
</section> </section>
); );
} }
function ReplaySnapshotHealth({ health }) {
if (!health) return null;
const replay = health.replay || { sources: [], readyCount: 0, totalCount: 0 };
const snapshots = health.snapshots || { rovers: [], rooms: [] };
const replaySummary = `${replay.readyCount}/${replay.totalCount} sources ready`;
const roverStale = snapshots.rovers.filter((entry) => entry.stale).length;
const roomStale = snapshots.rooms.filter((entry) => entry.stale).length;
return (
<div className="space-y-0.5">
<div className="panel-muted text-xs uppercase">Health</div>
<div className="surface space-y-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between">
<span>Replay segments</span>
<span className="text-slate-400">{replaySummary}</span>
</div>
<div className="flex items-center justify-between">
<span>Rover snapshots</span>
<span className={roverStale ? 'text-amber-300' : 'text-emerald-300'}>
{snapshots.rovers.length - roverStale}/{snapshots.rovers.length} ok
</span>
</div>
<div className="flex items-center justify-between">
<span>Room cameras</span>
<span className={roomStale ? 'text-amber-300' : 'text-emerald-300'}>
{snapshots.rooms.length - roomStale}/{snapshots.rooms.length} ok
</span>
</div>
</div>
<div className="space-y-0.5 text-xs text-slate-300">
{replay.sources.map((source) => (
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
<span>{source.label}</span>
<span className={source.ready ? 'text-emerald-300' : 'text-amber-300'}>
{source.recentCount}/{source.neededCount}
</span>
</div>
))}
</div>
<div className="space-y-0.5 text-xs text-slate-300">
{snapshots.rovers.map((entry) => (
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
<span>{entry.name}</span>
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>
{entry.stale ? 'stale' : 'ok'}
</span>
</div>
))}
</div>
<div className="space-y-0.5 text-xs text-slate-300">
{snapshots.rooms.map((entry) => (
<div key={`room:${entry.id}`} className="flex items-center justify-between">
<span>{entry.name}</span>
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>
{entry.error ? 'error' : entry.stale ? 'stale' : 'ok'}
</span>
</div>
))}
</div>
</div>
);
}
function ModerationPanel({ moderation, onBan, onTimeout, onUnban }) {
const [filter, setFilter] = useState('');
const [timeoutMinutes, setTimeoutMinutes] = useState(30);
const [reason, setReason] = useState('');
const users = moderation?.users || [];
const normalized = filter.trim().toLowerCase();
const filtered = normalized
? users.filter((user) => {
const label = user.nicknames?.[user.nicknames.length - 1] || user.id || '';
return (
label.toLowerCase().includes(normalized) ||
String(user.id).toLowerCase().includes(normalized) ||
String(user.lastSocketId || '').toLowerCase().includes(normalized)
);
})
: users;
const handleBan = async (user) => {
try {
await onBan({ userId: user.id }, reason.trim() || null);
} catch (err) {
alert(err.message);
}
};
const handleTimeout = async (user) => {
const durationMs = Math.max(1, Number(timeoutMinutes) || 0) * 60 * 1000;
try {
await onTimeout({ userId: user.id }, durationMs, reason.trim() || null);
} catch (err) {
alert(err.message);
}
};
const handleUnban = async (user) => {
try {
await onUnban({ userId: user.id });
} catch (err) {
alert(err.message);
}
};
return (
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="panel-muted text-xs uppercase">Moderation</span>
<span className="text-slate-500">{filtered.length} users</span>
</div>
<div className="surface space-y-0.5 text-xs text-slate-200">
<div className="flex flex-wrap items-center gap-0.5">
<input
className="field-input flex-1 min-w-[10rem] text-xs"
placeholder="Search users"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<input
className="field-input flex-1 min-w-[12rem] text-xs"
placeholder="Reason (optional)"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
<div className="flex items-center gap-0.25 text-xs text-slate-400">
<span>Timeout (min)</span>
<input
className="field-input w-16 text-xs"
type="number"
min="1"
value={timeoutMinutes}
onChange={(event) => setTimeoutMinutes(event.target.value)}
/>
</div>
</div>
{filtered.length === 0 ? (
<p className="text-xs text-slate-500">No users tracked yet.</p>
) : (
filtered
.slice()
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
.map((user) => {
const label = user.nicknames?.[user.nicknames.length - 1] || user.id.slice(0, 6);
const ban = user.ban || null;
return (
<div key={user.id} className="surface-muted space-y-0.25 text-xs">
<div className="flex flex-wrap items-center justify-between gap-0.5">
<div className="flex flex-wrap items-center gap-0.5">
<span className="text-slate-100">{label}</span>
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
{user.id.slice(0, 6)}
</span>
{ban && (
<span className="rounded bg-red-500/30 px-1 text-[0.7rem] text-red-200">
{ban.expiresAt ? 'Timeout' : 'Banned'}
</span>
)}
</div>
<div className="flex flex-wrap gap-0.25">
<button
type="button"
className="button-dark text-[0.7rem]"
onClick={() => handleBan(user)}
>
Ban
</button>
<button
type="button"
className="button-dark text-[0.7rem]"
onClick={() => handleTimeout(user)}
>
Timeout
</button>
<button
type="button"
className="button-danger text-[0.7rem]"
onClick={() => handleUnban(user)}
disabled={!ban}
>
Unban
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-0.5 text-[0.7rem] text-slate-400">
{user.lastSocketId && <span>socket {user.lastSocketId.slice(0, 6)}</span>}
{user.lastSeen && <span>last seen {new Date(user.lastSeen).toLocaleString()}</span>}
{ban?.expiresAt && (
<span>expires {new Date(ban.expiresAt).toLocaleString()}</span>
)}
{user.ips?.length ? (
<span>ips {user.ips.slice(-3).join(', ')}</span>
) : null}
</div>
</div>
);
})
)}
</div>
<ModerationBanList bans={moderation?.bans || []} onUnban={onUnban} />
</div>
);
}
function ModerationBanList({ bans, onUnban }) {
if (!bans.length) {
return (
<div className="surface text-xs text-slate-500">
No active bans/timeouts.
</div>
);
}
return (
<div className="surface space-y-0.5 text-xs text-slate-200">
<div className="panel-muted text-xs uppercase text-slate-400">Active bans</div>
{bans.map((ban) => (
<div key={ban.id} className="flex flex-wrap items-center justify-between gap-0.5">
<div className="flex flex-wrap items-center gap-0.5">
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
{ban.id.slice(0, 6)}
</span>
{ban.userId && (
<span className="text-[0.7rem] text-slate-400">user {ban.userId.slice(0, 6)}</span>
)}
<span className="text-[0.7rem] text-slate-300">
{ban.expiresAt ? 'timeout' : 'ban'}
</span>
{ban.expiresAt && (
<span className="text-[0.7rem] text-slate-400">
until {new Date(ban.expiresAt).toLocaleString()}
</span>
)}
{ban.reason ? (
<span className="text-[0.7rem] text-slate-400">reason {ban.reason}</span>
) : null}
</div>
<button
type="button"
className="button-danger text-[0.7rem]"
onClick={() => onUnban({ banId: ban.id })}
>
Unban
</button>
</div>
))}
</div>
);
}
function AdminIpLogPanel({ entries }) {
const logs = entries || [];
return (
<div className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-400">
<span>Admin IP log</span>
<span>{logs.length}</span>
</div>
<div className="surface h-64 overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<p>No admin log entries yet.</p>
) : (
logs
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className="surface">
<span className="text-amber-400">
{entry.ts ? new Date(entry.ts).toLocaleTimeString() : '--'}
</span>{' '}
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
<span className="text-slate-200">{entry.message}</span>{' '}
{entry.ip && <span className="text-cyan-300">{entry.ip}</span>}{' '}
{entry.meta && <span className="text-slate-500">{JSON.stringify(entry.meta)}</span>}
</div>
))
)}
</div>
<p className="text-xs text-slate-500">Admin-only log stream; IPs never appear in user data.</p>
</div>
);
}
+49 -13
View File
@@ -1,7 +1,10 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import ChatMessageRow from './ChatMessageRow.jsx';
import ChatTypingRow from './ChatTypingRow.jsx';
const LIFETIME_MS = 5000; const LIFETIME_MS = 3000;
const DEFAULT_COLOR = '#2196f3';
function buildKey(alert) { function buildKey(alert) {
if (alert.id) return alert.id; if (alert.id) return alert.id;
@@ -9,10 +12,10 @@ function buildKey(alert) {
return `${alert.title || 'alert'}-${alert.message}`; return `${alert.title || 'alert'}-${alert.message}`;
} }
export default function AlertFeed() { export default function AlertFeed({ scale = 1 }) {
const { alerts } = useSession(); const { alerts } = useSession();
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const latest = useMemo(() => alerts.slice(-5).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]); const latest = useMemo(() => alerts.slice(-3).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
useEffect(() => { useEffect(() => {
if (!latest.length) return undefined; if (!latest.length) return undefined;
@@ -29,8 +32,20 @@ export default function AlertFeed() {
if (!visible.length) return null; if (!visible.length) return null;
const containerStyle =
scale === 1
? undefined
: {
transform: `translateX(-50%) scale(${scale})`,
transformOrigin: 'top center',
};
const containerClass =
scale === 1
? 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5'
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
return ( return (
<div className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5"> <div className={containerClass} style={containerStyle}>
{visible.map((toast) => ( {visible.map((toast) => (
<AlertToast key={toast.key} alert={toast.alert} /> <AlertToast key={toast.key} alert={toast.alert} />
))} ))}
@@ -38,17 +53,38 @@ export default function AlertFeed() {
); );
} }
function hexToRgb(hex) {
const safe = typeof hex === 'string' ? hex.trim() : '';
const match = /^#?([0-9a-fA-F]{6})$/.exec(safe);
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function AlertToast({ alert }) { function AlertToast({ alert }) {
const colorClasses = if (alert.kind === 'chat' && alert.payload) {
alert.color === 'error' return <ChatMessageRow message={alert.payload} />;
? 'bg-red-500/30 text-red-100' }
: alert.color === 'warn' if (alert.kind === 'chat-typing' && alert.payload) {
? 'bg-amber-500/30 text-amber-100' return <ChatTypingRow message={alert.payload} />;
: 'bg-emerald-500/30 text-emerald-100'; }
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
return ( return (
<div className={`pointer-events-auto px-0.5 py-0.5 text-[0.85rem] ${colorClasses}`}> <div
<p className="text-xs text-slate-200">{alert.title || 'Alert'}</p> className="pointer-events-auto max-w-[80vw] rounded-full border px-2 py-0.5 text-[0.7rem] leading-tight text-slate-100 shadow-sm shadow-black/30"
<p className="text-sm text-white">{alert.message}</p> style={{ backgroundColor, border: `1px solid ${borderColor}` }}
>
<p className="truncate text-slate-100">
<span className="text-slate-300">{alert.title || 'Alert'}</span>
<span className="text-slate-500"> · </span>
<span className="text-white">{alert.message}</span>
</p>
</div> </div>
); );
} }
+43
View File
@@ -0,0 +1,43 @@
import AuthPanel from './AuthPanel.jsx';
import { useSession } from '../context/SessionContext.jsx';
function formatExpiry(expiresAt) {
if (!expiresAt) return 'permanent';
const ms = expiresAt - Date.now();
if (ms <= 0) return 'expiring soon';
const minutes = Math.ceil(ms / 60000);
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}`;
const hours = Math.ceil(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`;
const days = Math.ceil(hours / 24);
return `${days} day${days === 1 ? '' : 's'}`;
}
export default function BannedOverlay() {
const { banStatus } = useSession();
if (!banStatus?.banned) return null;
const expiresAt = banStatus.expiresAt || null;
const reason = banStatus.reason || null;
return (
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/90 px-0.5 py-0.5">
<div className="surface w-full max-w-xl space-y-0.5 text-slate-100 shadow-2xl">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-red-300">Access blocked</p>
<p className="text-sm text-slate-300">
Your access has been {expiresAt ? 'temporarily restricted' : 'banned'}.
</p>
<div className="text-xs text-slate-400">
{expiresAt ? `Timeout ends in ${formatExpiry(expiresAt)}.` : 'This ban has no expiration.'}
</div>
{reason && <div className="text-xs text-slate-400">Reason: {reason}</div>}
</div>
<div className="surface-muted">
<AuthPanel />
</div>
<p className="text-xs text-slate-500">
Admins can log in above to regain access.
</p>
</div>
</div>
);
}
+1 -1
View File
@@ -99,7 +99,7 @@ export default function CameraServoPanel() {
onTouchEnd={commitSlider} onTouchEnd={commitSlider}
onPointerUp={commitSlider} onPointerUp={commitSlider}
/> />
<div className="mt-0.5 flex justify-between text-xs text-slate-400"> <div className="mt-0 flex justify-between text-xs text-slate-400">
<span>{formatDegrees(min)}</span> <span>{formatDegrees(min)}</span>
<span>{formatDegrees(max)}</span> <span>{formatDegrees(max)}</span>
</div> </div>
+110
View File
@@ -0,0 +1,110 @@
import { FaDiscord } from 'react-icons/fa';
function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatTime(ts) {
const date = new Date(ts);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function displayName(message) {
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
}
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
return (
<span
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
title={label}
>
<span
className={`h-full w-1/2 bg-slate-700/70 ${guildIconUrl ? 'bg-cover' : ''}`}
style={
guildIconUrl
? {
backgroundImage: `url(${guildIconUrl})`,
backgroundPosition: 'left center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
<span
className={`h-full w-1/2 bg-slate-700/70 ${userAvatarUrl ? 'bg-cover' : ''}`}
style={
userAvatarUrl
? {
backgroundImage: `url(${userAvatarUrl})`,
backgroundPosition: 'right center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
</span>
);
}
export function ChatIdentity({ message }) {
const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null;
return (
<>
{message.fromDiscord ? (
<>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
</>
) : null}
<span className={`font-semibold text-[0.85rem] ${roleColors(message.role)}`}>
{displayName(message)}
</span>
{message.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{message.roverId}</span>
)}
</>
);
}
function chatRowClass(message) {
const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
isAdmin
? 'border border-amber-400/30'
: message.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`;
}
export default function ChatMessageRow({ message }) {
return (
<div className={chatRowClass(message)}>
<ChatIdentity message={message} />
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
{formatTime(message.ts)}
</span>
</div>
);
}
export { chatRowClass };
+83 -72
View File
@@ -1,42 +1,35 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatContext.jsx'; import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
function roleColors(role, fromDiscord) { import ChatMessageRow from './ChatMessageRow.jsx';
if (fromDiscord) return 'text-indigo-200'; import ChatTypingRow from './ChatTypingRow.jsx';
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatTime(ts) {
const date = new Date(ts);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function displayName(message) {
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
}
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl']; const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10); const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) { export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
const { session } = useSession(); const { session } = useSession();
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat(); const {
messages,
typing,
sendMessage,
registerInputRef,
onInputFocus,
onInputBlur,
blurChat,
setTypingActive,
} = useChat();
const {
value: ttsSettings,
save: saveTtsSettings,
} = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(false); const [speak, setSpeak] = useState(false);
const [engine, setEngine] = useState('flite'); const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState('rms'); const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
const [pitch, setPitch] = useState(50); const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
const canChat = session?.role !== 'spectator'; const canChat = session?.role !== 'spectator';
const listRef = useRef(null); const listRef = useRef(null);
const currentRoverId = session?.assignment?.roverId || null; const currentRoverId = session?.assignment?.roverId || null;
@@ -48,11 +41,21 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
const ttsSupported = Boolean(rover?.audio?.ttsEnabled); const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const sorted = useMemo(() => messages.slice(-200), [messages]); const sorted = useMemo(() => messages.slice(-200), [messages]);
const typingRows = useMemo(() => typing || [], [typing]);
useEffect(() => { useEffect(() => {
if (!listRef.current) return; if (!listRef.current) return;
listRef.current.scrollTop = listRef.current.scrollHeight; listRef.current.scrollTop = listRef.current.scrollHeight;
}, [sorted]); }, [sorted, typingRows]);
useEffect(() => {
const nextEngine = ttsSettings?.engine || 'flite';
const nextVoice = ttsSettings?.voice || 'rms';
const nextPitch = Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50;
if (engine !== nextEngine) setEngine(nextEngine);
if (voice !== nextVoice) setVoice(nextVoice);
if (pitch !== nextPitch) setPitch(nextPitch);
}, [engine, pitch, ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, voice]);
useEffect(() => { useEffect(() => {
if (ttsSupported) { if (ttsSupported) {
@@ -82,6 +85,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
await sendMessage(clean, ttsPayload); await sendMessage(clean, ttsPayload);
setDraft(''); setDraft('');
blurChat(); blurChat();
setTypingActive(false);
} catch (err) { } catch (err) {
alert(err.message); alert(err.message);
} finally { } finally {
@@ -93,57 +97,48 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
return ( return (
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}> <section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
<div className={`surface overflow-y-auto space-y-0.25 ${listClass}`} ref={listRef}> <div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
{sorted.length === 0 ? ( {sorted.length === 0 && typingRows.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p> <p className="text-sm text-slate-500">No messages yet.</p>
) : ( ) : (
sorted.map((msg) => { sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
const isAdmin =
msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin';
return (
<div
key={msg.id}
className={`surface-muted text-sm flex items-start gap-1 ${
isAdmin
? 'border border-amber-400/30'
: msg.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
<span className="text-[0.75rem] text-slate-400">{formatTime(msg.ts)}</span>
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role, msg.fromDiscord)}`}>
{displayName(msg)}
</span>
{msg.fromDiscord && (
<span className="rounded bg-indigo-500/30 px-1 text-[0.7rem] text-indigo-100">
Discord
</span>
)}
{msg.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {msg.roverId}</span>
)}
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{msg.text}</span>
</div>
);
})
)} )}
{typingRows.map((entry) => (
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
))}
</div> </div>
{!hideInput && ( {!hideInput && (
<form className="flex gap-0.5" onSubmit={handleSend}> <form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
<input <input
className="field-input flex-1" className="field-input flex-1 min-w-[10rem]"
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => {
onFocus={onInputFocus} const next = e.target.value;
onBlur={onInputBlur} setDraft(next);
ref={(el) => registerInputRef(el)} setTypingActive(Boolean(next.trim()));
}}
onFocus={(event) => {
onInputFocus(event);
setTypingActive(Boolean(draft.trim()));
}}
onBlur={(event) => {
onInputBlur(event);
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
setTypingActive(false);
}
}}
ref={(el) => registerInputRef(el, { target: 'panel' })}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'} placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat} disabled={!canChat}
/> />
{ttsSupported && ( {ttsSupported && (
<div className="flex items-center gap-0.5"> <div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
<label className="flex items-center gap-0.25 text-xs text-slate-300"> <label className="flex items-center gap-0.5 text-xs text-slate-300">
<input <input
type="checkbox" type="checkbox"
checked={speak} checked={speak}
@@ -154,7 +149,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
</label> </label>
<select <select
value={engine} value={engine}
onChange={(e) => setEngine(e.target.value)} onChange={(e) => {
const next = e.target.value;
setEngine(next);
saveTtsSettings((current) => ({ ...(current || {}), engine: next }));
}}
className="field-input text-xs" className="field-input text-xs"
> >
<option value="flite">flite</option> <option value="flite">flite</option>
@@ -163,7 +162,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
{engine === 'flite' ? ( {engine === 'flite' ? (
<select <select
value={voice} value={voice}
onChange={(e) => setVoice(e.target.value)} onChange={(e) => {
const next = e.target.value;
setVoice(next);
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
}}
className="field-input text-xs" className="field-input text-xs"
> >
{FLITE_VOICES.map((v) => ( {FLITE_VOICES.map((v) => (
@@ -175,7 +178,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
) : ( ) : (
<select <select
value={pitch} value={pitch}
onChange={(e) => setPitch(Number(e.target.value))} onChange={(e) => {
const next = Number(e.target.value);
setPitch(next);
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
}}
className="field-input text-xs" className="field-input text-xs"
> >
{ESPEAK_PITCHES.map((p) => ( {ESPEAK_PITCHES.map((p) => (
@@ -187,7 +194,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
)} )}
</div> </div>
)} )}
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50"> <button
type="submit"
disabled={!canChat || sending}
className="button-dark h-full disabled:opacity-50 self-stretch"
>
{sending ? '...' : 'Send'} {sending ? '...' : 'Send'}
</button> </button>
</form> </form>
+10
View File
@@ -0,0 +1,10 @@
import { ChatIdentity, chatRowClass } from './ChatMessageRow.jsx';
export default function ChatTypingRow({ message }) {
return (
<div className={`${chatRowClass(message)} italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
<ChatIdentity message={message} />
<span className="text-slate-300">typing...</span>
</div>
);
}
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
const MOBILE_DISMISS_MS = 10000;
const MAX_FONT_PX = 28;
const MIN_FONT_PX = 14;
export default function CommunityGoalBanner({ layout = 'desktop', className = '' }) {
const { session } = useSession();
const goalText = session?.communityGoal?.text ? String(session.communityGoal.text).trim() : '';
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
const [visible, setVisible] = useState(false);
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
const [remainingMs, setRemainingMs] = useState(0);
const containerRef = useRef(null);
const textRef = useRef(null);
const textContainerRef = useRef(null);
useEffect(() => {
if (!goalText) {
setVisible(false);
return undefined;
}
setVisible(true);
if (!isMobile) return undefined;
const startedAt = Date.now();
setRemainingMs(MOBILE_DISMISS_MS);
const timer = setTimeout(() => setVisible(false), MOBILE_DISMISS_MS);
const tick = setInterval(() => {
const elapsed = Date.now() - startedAt;
setRemainingMs(Math.max(0, MOBILE_DISMISS_MS - elapsed));
}, 250);
return () => {
clearTimeout(timer);
clearInterval(tick);
};
}, [goalText, isMobile]);
useEffect(() => {
if (!goalText) return undefined;
setFontSize(MAX_FONT_PX);
let rafId = 0;
const adjustFont = () => {
const textContainer = textContainerRef.current;
const text = textRef.current;
if (!textContainer || !text) return;
const available = textContainer.clientWidth;
if (!available) return;
const needed = text.scrollWidth;
if (!needed) return;
const scale = Math.min(1, available / needed);
const nextSize = Math.max(MIN_FONT_PX, Math.floor(MAX_FONT_PX * scale));
setFontSize(nextSize);
};
rafId = window.requestAnimationFrame(adjustFont);
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(adjustFont);
});
if (containerRef.current) observer.observe(containerRef.current);
return () => {
window.cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [goalText]);
const containerClass = useMemo(
() =>
[
'panel-section flex w-full items-center justify-center',
isMobile ? 'rounded-none' : 'rounded',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
]
.filter(Boolean)
.join(' '),
[className, isMobile],
);
if (!goalText || !visible) return null;
const remainingSeconds = isMobile ? Math.ceil(remainingMs / 1000) : null;
return (
<div
ref={containerRef}
className={containerClass}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
onClick={() => setVisible(false)}
role="button"
tabIndex={0}
>
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap">
<span className="flex flex-col justify-center border-r border-slate-700/60 px-0.5 text-[0.55em] font-semibold leading-tight text-slate-400">
<span>Community</span>
<span>Goal</span>
</span>
<span ref={textContainerRef} className="flex-1 overflow-hidden text-slate-100">
<span ref={textRef} className="block">
{goalText}
</span>
</span>
{isMobile ? (
<span className="flex items-center border-l border-slate-700/60 px-0.5 text-[0.55em] font-semibold uppercase tracking-wide text-slate-400">
{remainingSeconds}s
</span>
) : null}
</span>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js';
import TopDownMap from './TopDownMap.jsx';
import RoverRoster from './RoverRoster.jsx';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
export function RoverRosterPanel({ title = 'Rovers' }) {
const { session, requestControl } = useSession();
const [pending, setPending] = useState({});
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
async function handleRequest(targetRoverId) {
if (!targetRoverId) return;
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
try {
await requestControl(targetRoverId);
} catch (err) {
alert(err.message);
} finally {
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
}
}
return (
<RoverRoster
title={title}
roster={session?.roster ?? []}
renderActions={(rover) =>
canRequest ? (
<button
type="button"
onClick={() => handleRequest(rover.id)}
disabled={pending[rover.id]}
className="button-dark disabled:opacity-40"
>
{pending[rover.id] ? '...' : 'request'}
</button>
) : null
}
/>
);
}
export default function ControlSummary() {
const {
state: { roverId, keymap },
} = useControlSystem();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const driveDockState = useDriveDockState(roverId);
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
return (
<section className="panel-section">
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
<div className="flex h-full w-full items-stretch justify-center">
<div className="aspect-square h-full w-full">
<TopDownMap sensors={sensors} />
</div>
</div>
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.5">
<div className="row-span-2">
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
</div>
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
</div>
</div>
</section>
);
}
export function InlineCameraTilt({ keymap }) {
const {
state: { roverId, camera },
actions: { setServoAngle },
} = useControlSystem();
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
typeof camera?.angle === 'number'
? camera.angle
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
const [pendingAngle, setPendingAngle] = useState(value);
const draggingRef = useRef(false);
useEffect(() => {
if (!draggingRef.current) {
setPendingAngle(value);
}
}, [value]);
const handleSlider = (event) => {
const next = Number.parseFloat(event.target.value);
if (Number.isNaN(next)) return;
draggingRef.current = true;
setPendingAngle(next);
setServoAngle(next);
};
const commitSlider = () => {
draggingRef.current = false;
};
if (!enabled) return null;
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
return (
<div className="surface space-y-0.5 px-1 py-1 text-sm text-slate-200">
<div className="flex items-center justify-between text-xs text-slate-300">
<span>Camera tilt</span>
<span className="font-mono text-slate-100">{formatDegrees(value)}</span>
</div>
<div>
<input
type="range"
className="w-full accent-emerald-400"
min={min}
max={max}
step={0.5}
value={pendingAngle}
onChange={handleSlider}
onMouseUp={commitSlider}
onTouchEnd={commitSlider}
onPointerUp={commitSlider}
/>
<div className="mt-0 flex items-center justify-between text-[0.7rem] text-slate-400">
<span className="flex items-center gap-0.5">
{downLabel ? <KeyPill label={downLabel} /> : null}
{formatDegrees(min)}
</span>
<span className="flex items-center gap-0.5">
{formatDegrees(max)}
{upLabel ? <KeyPill label={upLabel} /> : null}
</span>
</div>
</div>
</div>
);
}
function KeyPill({ label }) {
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
function formatDegrees(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { useSession } from "../context/SessionContext"; import { useSession } from "../context/SessionContext";
import { FaDiscord } from "react-icons/fa"; import { FaDiscord } from "react-icons/fa";
export default function DiscordInviteButton({text = "Join our Discord!"}) { export default function DiscordInviteButton({ text = "Join our Discord!", className = "" }) {
const { session } = useSession(); const { session } = useSession();
const discordInvite = session?.discord?.invite || null; const discordInvite = session?.discord?.invite || null;
@@ -11,12 +11,12 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
<a <a
href={discordInvite} href={discordInvite}
target="_blank" target="_blank"
rel="noopener noreferrer"npm rel="noopener noreferrer"
className="inline-flex items-center px-3 py-2 text-white rainbow-animate-bg transition justify-center" className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center gap-1 ${className}`}
// animated rainbow backgound // animated rainbow backgound
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition" // className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
> >
<FaDiscord className="mr-2" /> <FaDiscord className="mr-0" />
{text} {text}
</a> </a>
); );
+310
View File
@@ -0,0 +1,310 @@
import { useMemo, useState } from 'react';
import { useControlSystem } from '../controls/index.js';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { formatKeyLabel } from '../controls/keymapUtils.js';
export function deriveDriveDockState(frame) {
const sensors = frame?.sensors || {};
const oiLabel = sensors.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const chargingLabel = sensors.chargingState?.label || '';
const docked = Boolean(sensors.chargingSources?.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const driving = oiNormalized === 'full';
const dockedNotCharging = docked && !charging;
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
}
export function useDriveDockState(roverId) {
const frame = useTelemetryFrame(roverId);
return useMemo(() => deriveDriveDockState(frame), [frame]);
}
function StatusPill({ label, active }) {
return (
<span
className={`rounded-xl px-1.5 py-0.5 text-[0.75rem] font-semibold ${
active ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'
}`}
>
{label}
</span>
);
}
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
function ActionPill({ label, tone }) {
const toneClasses =
tone === 'emerald'
? 'border-emerald-200/70 bg-emerald-600/70 text-emerald-50'
: tone === 'amber'
? 'border-amber-200/70 bg-amber-600/70 text-amber-50'
: 'border-indigo-200/70 bg-indigo-600/70 text-indigo-50';
return (
<span
className={`rounded-full border px-0.5 py-0.15 text-[0.7rem] font-semibold ${toneClasses}`}
>
{label}
</span>
);
}
function DockModal({ instructions, onConfirm, onCancel, pending }) {
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
<p className="text-sm text-slate-200">{instructions.summary}</p>
<StepList steps={instructions.steps} tone="indigo" />
</div>
<div className="grid grid-cols-2 gap-0.5 text-sm">
<button
type="button"
onClick={onCancel}
className="bg-slate-700 px-0.5 py-1 font-semibold text-slate-100 transition hover:bg-slate-600"
>
Cancel
</button>
<button
type="button"
disabled={pending}
onClick={onConfirm}
className="bg-indigo-600 px-0.5 py-1 font-semibold text-indigo-50 transition hover:bg-indigo-500 disabled:opacity-50"
>
{pending ? 'Docking…' : 'Confirm Dock'}
</button>
</div>
</div>
</div>
);
}
export default function DriveDockAction({ layout = 'desktop', expand = false, driveDockState }) {
const isMobile = layout === 'mobile';
const {
state: { roverId, keymap },
actions,
} = useControlSystem();
const frame = useTelemetryFrame(roverId);
const state = driveDockState ?? deriveDriveDockState(frame);
const { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel } = state;
const [pending, setPending] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [showModal, setShowModal] = useState(false);
const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null;
const driveKeyLabel = formatKeyLabel(keymap?.driveMacro?.[0]);
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
const dockInstructions = {
summary: 'You are about to trigger an automatic docking attempt! To have a successful dock:',
steps: ['Line up the rover straight in front of the dock', 'Make sure the rover is about one foot or half a meter away from the dock', 'Press Confirm Dock to begin the docking process'],
};
const dockButtonCaption = 'Click this button to initate auto-dock.';
const startDriveInstructions = {
summary: 'You must enable driving mode before you can move the rover.',
steps: ['Press the keybind or tap this button to enable driving mode', 'Once ready (should be instant), this dialog will change, and you will be able to move'],
};
const handleReturnToDrive = async () => {
if (!roverId || pending) return;
setPending('drive');
try {
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
};
const handleStartDrive = async () => {
if (!roverId || pending) return;
setConfirmOpen(false);
setShowModal(false);
setPending('drive');
try {
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
};
const handleConfirmDock = async () => {
if (!roverId || pending) return;
setPending('dock');
try {
actions.setMode('dock');
await actions.runMacro('seek-dock');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
setConfirmOpen(false);
setShowModal(false);
}
};
const handleOpenDock = () => {
if (dockDisabled) return;
setShowModal(true);
setConfirmOpen(true);
};
const baseCardClasses =
'flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60';
const ctaText = 'text-center';
const ctaLayout = 'items-center justify-between';
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
const emeraldCta =
'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700 focus-visible:ring-emerald-300';
const amberCta =
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
const indigoCta =
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
const filledHeight = expand ? 'h-full flex-1' : '';
if (!driving && !dockingInProgress) {
return (
<button
type="button"
onClick={handleStartDrive}
disabled={driveDisabled}
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
>
<div className="space-y-0.5 w-full">
<div className="flex flex-wrap items-center justify-center gap-0.5">
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
{!isMobile ? <ActionPill label="Click to start" tone="emerald" /> : null}
</div>
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
<StepList steps={startDriveInstructions.steps} tone="emerald" />
</div>
<div className="flex w-full flex-col gap-0.5">
<StatusPill label={`OI: ${oiLabel}`} active={oiLabel.toLowerCase() === 'full'} />
<StatusPill label={docked ? 'Docked!' : 'Not docked'} active={docked} />
<StatusPill label={charging ? 'Charging!' : 'Not charging'} active={charging} />
</div>
</button>
);
}
if (dockingInProgress) {
const inProgressCopy = {
summary: 'Docking attempt in progress.',
steps: ['The rover should be slowly wiggling towards the dock', 'If it is obviously not working, press this button to enter driving mode and try again'],
};
return (
<button
type="button"
disabled={driveDisabled}
onClick={handleReturnToDrive}
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
>
<div className="space-y-0.5 w-full">
<div className="flex flex-wrap items-center justify-center gap-0.5">
<span className="text-base font-semibold text-amber-50">Docking in Progress</span>
{!isMobile ? <ActionPill label="Click to return to driving mode" tone="amber" /> : null}
</div>
<p className="text-sm text-amber-50/90">{inProgressCopy.summary}</p>
</div>
<div className="flex w-full flex-col gap-0.5">
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
</div>
<StepList steps={inProgressCopy.steps} tone="amber" />
</button>
);
}
return (
<>
<button
type="button"
disabled={dockDisabled}
onClick={handleOpenDock}
className={
isMobile
? `flex w-full items-center justify-center ${baseCardClasses} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
: `${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
}
>
<div className="flex flex-wrap items-center justify-center gap-0.5 w-full">
<span className="text-base font-semibold text-indigo-50">Dock and Charge</span>
{!isMobile && dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
{!isMobile ? <ActionPill label="Click to begin docking" tone="indigo" /> : null}
</div>
{!isMobile && (
<>
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
<div className="flex w-full flex-col gap-0.5">
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
{dockedNotCharging ? (
<StatusPill label="Not charging yet" active={false} />
) : null}
</div>
<div className="text-xs text-indigo-100/80">
Line up straight, about 1 foot away, before docking.
</div>
</>
)}
</button>
{showModal || (!isMobile && confirmOpen) ? (
<DockModal
instructions={dockInstructions}
pending={pending === 'dock'}
onCancel={() => {
setShowModal(false);
setConfirmOpen(false);
}}
onConfirm={handleConfirmDock}
/>
) : null}
</>
);
}
function StepList({ steps, tone = 'emerald' }) {
const container =
tone === 'emerald'
? 'bg-emerald-900/60 text-emerald-50/90 border-emerald-700/60'
: tone === 'amber'
? 'bg-amber-900/60 text-amber-50/90 border-amber-700/60'
: 'bg-indigo-900/60 text-indigo-50/90 border-indigo-700/60';
const numberColor =
tone === 'emerald'
? 'text-emerald-200'
: tone === 'amber'
? 'text-amber-200'
: 'text-indigo-200';
return (
<div className={`space-y-0.5 rounded border px-0.5 py-0.35 text-left ${container}`}>
{steps.map((step, idx) => (
<div key={step} className="text-[0.85rem] leading-snug break-words">
<div className="flex items-start">
<span className={`mr-0 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
<span className="align-top">{step}</span>
</div>
{idx < steps.length - 1 ? <div className="mt-0 h-px bg-white/10" /> : null}
</div>
))}
</div>
);
}
+6 -5
View File
@@ -35,16 +35,16 @@ export default function DrivePanel() {
</div> */} </div> */}
<div className="space-y-0.5"> <div className="space-y-0.5">
<ActionCard <ActionCard
title="Start Driving" title="Startfdsafsdfakjfdhlkfshlkjss Driving"
description="Press to enable driving mode, then start moving." description="Press to enable driving mode, then start moving."
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]} statuses={[{ label: drivingMode ? 'Ready!' : 'Press to enter driving mode!', active: drivingMode }]}
tone="emerald" tone="emerald"
onClick={handleStartDrive} onClick={handleStartDrive}
disabled={!roverId} disabled={!roverId}
/> />
<ActionCard <ActionCard
title="Dock and Charge" title="Dock and Charge"
description="Line the rover up about a foot from the dock, then trigger an automatic docking attempt." description="Line the rover up about a foot from the dock, then press to trigger a docking attempt."
statuses={[ statuses={[
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked }, { label: docked ? 'Docked!' : 'Not Docked!', active: docked },
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging }, { label: charging ? 'Charging!' : 'Not Charging!', active: charging },
@@ -55,6 +55,7 @@ export default function DrivePanel() {
/> />
</div> </div>
{/* <DiscordInviteButton /> */} {/* <DiscordInviteButton /> */}
{/* kjfhdljhdsaflkjf */}
</section> </section>
); );
} }
@@ -74,7 +75,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
<p className="text-base font-semibold">{title}</p> <p className="text-base font-semibold">{title}</p>
<p className="text-sm text-white/90">{description}</p> <p className="text-sm text-white/90">{description}</p>
{/* center statuses in button */} {/* center statuses in button */}
<div className="mt-0.5 flex flex-wrap gap-0.5 items-center w-full justify-center"> <div className="mt-0 flex flex-wrap gap-0.5 items-center w-full justify-center">
{statuses.map((status) => ( {statuses.map((status) => (
<span <span
key={status.label} key={status.label}
@@ -86,7 +87,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
</span> </span>
))} ))}
</div> </div>
{footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>} {footnote && <p className="mt-0 text-xs text-emerald-50/80">{footnote}</p>}
</button> </button>
); );
} }

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