Compare commits

...
Author SHA1 Message Date
legop3 3a8bb8a0cf i give up lol 2026-01-17 20:35:03 -05:00
legop3 87936510b2 oops forgot to build UI 2026-01-17 20:29:41 -05:00
legop3 66b8c839b7 pass 2 2026-01-17 20:28:16 -05:00
legop3 7041a3c6df pass 1 2026-01-17 19:32:36 -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
131 changed files with 9146 additions and 815 deletions
+3
View File
@@ -12,3 +12,6 @@ robots.json
roverd-dummy
server/config.yaml
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
name: dummy1
serverUrl: ws://192.168.0.84:8080/rover
serverUrl: ws://127.0.0.1:8080/rover
serial:
device: /dev/ttyS0
baud: 115200
@@ -18,3 +18,5 @@ media:
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
run_pipeline() {
"${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-rtbufsize 0 \
-thread_queue_size 4096 \
-f s32le \
-ar "${AUDIO_RATE}" \
-ac "${AUDIO_CHANNELS}" \
-i <(arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw) \
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=30dB" \
-c:a libopus \
-b:a 24000 \
-ar:a 16000 \
-ac:a 1 \
-application lowdelay \
-frame_duration 20 \
-compression_level 0 \
-f mpegts \
"${AUDIO_PUBLISH_URL}"
arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw \
| "${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-rtbufsize 0 \
-thread_queue_size 4096 \
-f s32le \
-ar "${AUDIO_RATE}" \
-ac "${AUDIO_CHANNELS}" \
-i pipe:0 \
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=25dB" \
-c:a libopus \
-b:a 24000 \
-ar:a 16000 \
-ac:a 1 \
-application lowdelay \
-frame_duration 20 \
-compression_level 0 \
-f mpegts \
"${AUDIO_PUBLISH_URL}"
}
trap 'kill 0 2>/dev/null' EXIT INT TERM
while true; do
if run_pipeline; then
exit 0
fi
echo "Audio-only publisher exited, restarting in 2s..." >&2
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
sleep 2
done
+6 -3
View File
@@ -52,10 +52,13 @@ run_pipeline() {
--bitrate "${VIDEO_BITRATE}" \
--codec h264 \
--profile baseline \
--denoise cdn_off \
--denoise auto \
--nopreview \
--awb custom \
--awbgains 1.5,1.8 \
--metering centre \
--ev 0.1 \
--awb auto \
--saturation 0.6 \
--brightness 0 \
--output - \
| "${FFMPEG_BIN_PATH}" \
-hide_banner \
+7 -11
View File
@@ -42,7 +42,7 @@ func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, er
rpio.Close()
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
}
@@ -118,6 +118,9 @@ func (s *CameraServo) angleToPulse(angle float64) int {
}
norm := (angle - s.cfg.MinAngle) / totalRange
norm = math.Max(0, math.Min(1, norm))
if s.cfg.Invert {
norm = 1 - norm
}
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
}
@@ -129,15 +132,8 @@ func (s *CameraServo) pulseToAngle(pulse int) float64 {
}
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
norm = math.Max(0, math.Min(1, norm))
if s.cfg.Invert {
norm = 1 - norm
}
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
}
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()
}
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)
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
for ctx.Err() == nil {
+18
View File
@@ -8,6 +8,7 @@ type helloMessage struct {
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
NightVision NightVisionConfig `json:"nightVision"`
}
type sensorMessage struct {
@@ -26,6 +27,8 @@ type inboundMessage struct {
Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
}
type driveDirectPayload struct {
@@ -61,6 +64,21 @@ type ttsPayload struct {
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 string `json:"type"`
ID string `json:"id"`
+39 -1
View File
@@ -93,6 +93,14 @@ type CameraServoConfig struct {
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
Invert bool `yaml:"invert" json:"invert"`
}
type NightVisionConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
InitialOn bool `yaml:"initialOn" json:"initialOn"`
}
type Config struct {
@@ -105,6 +113,7 @@ type Config struct {
Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
}
func LoadConfig(path string) (*Config, error) {
@@ -154,6 +163,12 @@ func LoadConfig(path string) (*Config, error) {
DefaultVoice: "rms",
DefaultPitch: 50,
},
NightVision: NightVisionConfig{
Enabled: true,
GPIOPin: 22,
GPIOChip: "gpiochip0",
InitialOn: true,
},
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
@@ -214,6 +229,9 @@ func LoadConfig(path string) (*Config, error) {
if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err)
}
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
return nil, fmt.Errorf("nightVision: %w", err)
}
validateAudioConfig(&cfg.Audio)
return &cfg, nil
}
@@ -231,9 +249,16 @@ func validateServoConfig(cfg *CameraServoConfig) error {
if cfg.CycleLen <= 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")
}
if cfg.MinPulseUs == cfg.MaxPulseUs {
return errors.New("minPulseUs/maxPulseUs cannot be equal")
}
if cfg.MinPulseUs > cfg.MaxPulseUs {
cfg.MinPulseUs, cfg.MaxPulseUs = cfg.MaxPulseUs, cfg.MinPulseUs
cfg.Invert = !cfg.Invert
}
if cfg.MinAngle >= cfg.MaxAngle {
return errors.New("minAngle must be less than maxAngle")
}
@@ -278,6 +303,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) {
if streamName == "" {
return "", errors.New("missing stream name for publishUrl")
+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
View File
@@ -32,6 +32,7 @@ cameraServo:
cycleLen: 20000
minPulseUs: 900
maxPulseUs: 2100
invert: false
minAngle: -15
maxAngle: 30
homeAngle: 0
@@ -47,3 +48,8 @@ audio:
defaultEngine: flite
defaultVoice: rms
defaultPitch: 50
nightVision:
enabled: true
gpioPin: 22
gpioChip: gpiochip0
initialOn: true
+1 -1
View File
@@ -14,7 +14,7 @@ import (
const (
sensorHeader = 19
sensorReadTimeout = 150 * time.Millisecond
sensorThrottleMinimum = 250 * time.Millisecond
sensorThrottleMinimum = 50 * time.Millisecond
)
type SensorStreamer struct {
+27
View File
@@ -92,6 +92,33 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
return s.write(raw)
}
func (s *SerialAdapter) StartOI() error {
return s.write([]byte{128})
}
func (s *SerialAdapter) SeekDock() error {
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
}
func (s *SerialAdapter) StartOI() error {
s.log.Printf("[dummy] start OI")
return nil
}
func (s *SerialAdapter) SeekDock() error {
s.log.Printf("[dummy] seek dock")
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"
)
func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) error {
if payload == nil {
return fmt.Errorf("tts payload required")
}
@@ -44,7 +44,7 @@ func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
}
pitch = clampInt(pitch, 0, 99)
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
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, text)
cmd = exec.CommandContext(ctx, "espeak", args...)
cmd = exec.CommandContext(runCtx, "espeak", args...)
case "flite", "f":
args := []string{}
if voice != "" {
args = append(args, "-voice", voice)
}
args = append(args, "-t", text)
cmd = exec.CommandContext(ctx, "flite", args...)
cmd = exec.CommandContext(runCtx, "flite", args...)
default:
return fmt.Errorf("unsupported tts engine: %s", engine)
}
+204 -9
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"nhooyr.io/websocket"
@@ -15,13 +16,25 @@ type WSClient struct {
cfg *Config
adapter *SerialAdapter
sensorFrames <-chan []byte
events <-chan RoverEvent
events chan RoverEvent
media *MediaSupervisor
servo *CameraServo
nightVision *NightVisionLight
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{
cfg: cfg,
adapter: adapter,
@@ -29,16 +42,21 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
events: events,
media: media,
servo: servo,
nightVision: nightVision,
log: logger,
ttsQueue: ttsQueue,
}
}
func (c *WSClient) Run(ctx context.Context) error {
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
if err != nil {
c.markDisconnected()
return err
}
c.markConnected()
defer conn.Close(websocket.StatusInternalError, "closed")
defer c.markDisconnected()
if err := c.sendHello(ctx, conn); err != nil {
return err
@@ -48,6 +66,7 @@ func (c *WSClient) Run(ctx context.Context) error {
}
errCh := make(chan error, 1)
c.startTTSWorker(ctx)
go func() {
errCh <- c.readLoop(ctx, conn)
}()
@@ -72,6 +91,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio,
NightVision: c.cfg.NightVision,
}
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg)
@@ -123,12 +143,9 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.adapter.MotorPWM(main, side, vac)
case msg.SensorStream != nil:
if msg.SensorStream.Enable {
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
return err
}
return c.adapter.PauseSensorStream(false)
return c.adapter.StartSensorStream(defaultStreamPackets)
}
return c.adapter.PauseSensorStream(true)
return nil
case msg.Raw != "" && len(msg.Raw) > 0:
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
if err != nil {
@@ -152,12 +169,57 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
}
return c.handleServoCommand(msg.Servo)
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:
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 {
switch {
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) {
const (
sensorSilenceTimeout = 5 * time.Second
sensorRecoveryCooldown = 3 * time.Second
sensorCommandPause = 50 * time.Millisecond
)
timer := time.NewTimer(sensorSilenceTimeout)
defer timer.Stop()
resetTimer := func() {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(sensorSilenceTimeout)
}
lastRecovery := time.Time{}
lastFrame := time.Now()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
now := time.Now()
if !lastRecovery.IsZero() && now.Sub(lastRecovery) < sensorRecoveryCooldown {
resetTimer()
continue
}
idleFor := now.Sub(lastFrame)
if idleFor < 0 {
idleFor = sensorSilenceTimeout
}
c.recoverSensorStream(idleFor, sensorCommandPause)
lastRecovery = now
resetTimer()
case frame := <-c.sensorFrames:
lastFrame = time.Now()
resetTimer()
msg := sensorMessage{
Type: "sensor",
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 {
data, err := json.Marshal(v)
if err != nil {
@@ -232,7 +348,86 @@ func (c *WSClient) ensureSensorStream() error {
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
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 {
+2
View File
@@ -9,6 +9,8 @@ User=roverd
Group=roverd
EnvironmentFile=/var/lib/roverd/video.env
ExecStart=/usr/local/bin/audio-only-publisher
KillMode=control-group
TimeoutStopSec=5
Restart=on-failure
RestartSec=2
+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
- 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:
- https://www.thingiverse.com/thing:2873677
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
- https://www.thingiverse.com/thing:4514531
## spectator page
- completely remake it from scratch
## todo
<!-- 1. battery manager -->
<!-- 2. pi-side sensor throttle (1/5th) -->
@@ -22,6 +19,9 @@
<!-- 9. online user list -->
<!-- 10. chat -->
<!-- 9. discord invite button -->
10. redo both mobile layouts
11. redo spectator view (last)
21. finally.. set the favicon and title
<!-- 10. redo both mobile layouts -->
<!-- 11. redo spectator view (last) -->
<!-- 21. finally.. set the favicon and title -->
22. rover snapshots freezing and never coming back
23. rover snapshot -> video switching needs to be smoother, no black flash. connect and play before showing.
24. rover snapshots delayed (not just because of framerate)
+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
# Configurable via environment
DEVICE="${DEVICE:-/dev/video0}"
RESOLUTION="${RESOLUTION:-640x480}"
QUALITY="${QUALITY:-5}" # ffmpeg MJPEG quality (lower is better)
PORT="${PORT:-8088}"
WORKDIR="${WORKDIR:-/run/roomcam}"
export DEVICE="${DEVICE:-/dev/video0}"
export RESOLUTION="${RESOLUTION:-640x480}"
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
export PORT="${PORT:-8088}"
export WORKDIR="${WORKDIR:-/run/roomcam}"
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
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}"
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
rm -f "${SNAPSHOT_PATH}"
# push
cleanup() {
[[ -n "${FFMPEG_PID:-}" ]] && kill "${FFMPEG_PID}" 2>/dev/null || true
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 0' SIGTERM INT
FFMPEG_INPUT_ARGS=(-f v4l2 -input_format "${INPUT_FORMAT}" -video_size "${RESOLUTION}" -i "${DEVICE}")
FFMPEG_FILTERS=()
if [[ "${INPUT_FORMAT}" == bayer_* ]]; then
# Convert raw Bayer to a JPEG-friendly pixel format.
FFMPEG_FILTERS=(-pix_fmt yuv420p)
fi
cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
import os
import threading
import time
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
/usr/bin/ffmpeg -y \
-loglevel warning -nostats \
"${FFMPEG_INPUT_ARGS[@]}" \
"${FFMPEG_FILTERS[@]}" \
-q:v "${QUALITY}" \
-f image2 -update 1 "${SNAPSHOT_PATH}" &
FFMPEG_PID=$!
DEVICE = os.environ.get("DEVICE", "/dev/video0")
RESOLUTION = os.environ.get("RESOLUTION", "640x480")
INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
/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=$!
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=WORKDIR=/run/roomcam
Environment=INPUT_FORMAT=mjpeg
WorkingDirectory=/home/daniel/gits/MultiRoombaRover/roomcam-service
ExecStart=/usr/bin/env bash /home/daniel/gits/MultiRoombaRover/roomcam-service/room-cam-snapshot.sh
ExecStart=/usr/bin/env bash /usr/local/bin/room-cam-snapshot.sh
Restart=always
RestartSec=2
User=root
+14 -1
View File
@@ -7,11 +7,21 @@ admins:
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
discord_id: "0987654321"
lockdown: true
timezone: "America/New_York"
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
preview:
enabled: false
codec: "av1"
transport: "rtsp"
fps: 10
width: 640
roomBitrateKbps: 200
roverBitrateKbps: 350
gopSeconds: 2
homeAssistant:
url: "http://homeassistant.local:8123"
@@ -27,10 +37,12 @@ roomCameras:
name: "Lobby Camera"
description: "Wide shot of the staging area."
url: "http://192.168.0.50/snapshot.jpg"
streamUrl: "http://192.168.0.50/stream.mjpg"
- id: "workshop"
name: "Workshop Bench"
description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
discord:
token: "DISCORD_BOT_TOKEN"
@@ -38,7 +50,8 @@ discord:
channels:
announcements: "123456789012345678"
adminAlerts: "123456789012345678"
chatBridge: "123456789012345678"
# chat bridge is configured per guild via `rs bridge` commands
replay: "123456789012345678"
roles:
announcementPing: "123456789012345678"
adminPing: "123456789012345678"
+6
View File
@@ -17,13 +17,19 @@ require('./src/services/roverConnectionService');
require('./src/services/assignmentService');
require('./src/services/nicknameService');
require('./src/services/chatService');
require('./src/services/communityGoalService');
require('./src/services/videoSessions');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
require('./src/services/roomCameraSocketService');
require('./src/services/roverSnapshotSocketService');
require('./src/services/embedHttpService');
require('./src/services/logStreamService');
require('./src/services/homeAssistantService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/replaySocketService');
require('./src/services/replaySegmentManager');
require('./src/services/media/previewTranscoderService');
require('./src/services/discordBotService');
require('./src/services/httpServer');
+9
View File
@@ -8,6 +8,8 @@ MEDIAMTX_CONF_DIR="/etc/mediamtx"
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.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
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"
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
[Unit]
Description=mediaMTX WebRTC Server
@@ -84,6 +90,7 @@ Wants=network-online.target
User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$MEDIAMTX_CONF_DIR
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
Restart=on-failure
RestartSec=2
@@ -104,6 +111,8 @@ Group=$TARGET_USER
WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production
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
Restart=on-failure
RestartSec=2
+1 -1
View File
@@ -8,7 +8,7 @@ metricsAddress: 0.0.0.0:9998
pprof: no
pprofAddress: 127.0.0.1:9999
rtsp: no
rtsp: yes
rtmp: no
hls: no
+1
View File
@@ -14,6 +14,7 @@
"home-assistant-js-websocket": "^3.1.2",
"js-yaml": "^4.1.1",
"morgan": "^1.10.0",
"sharp": "^0.33.5",
"socket.io": "^4.7.5",
"uuid": "^9.0.1",
"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-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CnvHZcbU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css">
<script type="module" crossorigin src="/assets/index-B7raLs13.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
</head>
<body>
<div id="root"></div>
+1 -5
View File
@@ -1,4 +1,3 @@
const path = require('path');
const http = require('http');
const express = require('express');
const morgan = require('morgan');
@@ -7,10 +6,7 @@ const config = require('./config');
const app = express();
app.use(morgan('dev'));
app.use(express.json());
app.use(express.static(config.staticDir));
app.get('/spectate', (req, res) => {
res.sendFile(path.join(config.staticDir, 'index.html'));
});
app.use(express.static(config.staticDir, { index: false }));
const httpServer = http.createServer(app);
+3
View File
@@ -3,6 +3,9 @@ const { httpServer } = require('./http');
const io = new SocketIOServer(httpServer, {
cors: { origin: '*' },
transports: ['websocket', 'polling'],
pingInterval: 5000,
pingTimeout: 7000,
});
// 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.emit('connection', ws, req);
});
} else {
socket.destroy();
}
});
+1 -9
View File
@@ -1,15 +1,8 @@
const io = require('../globals/io');
const COLORS = {
info: '#2196f3',
success: '#4caf50',
warn: '#f0b651',
error: '#e53935',
};
function sendAlert({ color, title, message, ts = Date.now() }) {
const payload = {
color: color || COLORS.info,
color: color || '#2196f3',
title,
message,
ts,
@@ -19,6 +12,5 @@ function sendAlert({ color, title, message, ts = Date.now() }) {
}
module.exports = {
COLORS,
sendAlert,
};
+20
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) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
return;
@@ -177,4 +186,15 @@ module.exports = {
assignmentEvents,
describeAssignment,
forceRelease,
getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
if (!socket || !roverId) return;
const previous = assignments.get(socket.id);
if (previous && previous !== roverId && releasePrevious) {
roverManager.releaseControl(previous, socket);
}
assignments.set(socket.id, roverId);
waiting.delete(socket.id);
assignmentEvents.emit('update', socket.id);
},
};
+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;
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('lock', handleLockEvent);
+46 -8
View File
@@ -12,7 +12,10 @@ const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
const rateBuckets = new Map(); // socketId -> [timestamps]
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
const MAX_HISTORY = 100;
const history = [];
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick', 'faggot', 'fag', 'whore'];
const DUPLICATE_WINDOW_MS = 15000;
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
@@ -71,12 +74,27 @@ function buildMessage(socket, text, meta = {}) {
role: meta.role || getRole(socket),
roverId,
fromDiscord: Boolean(meta.fromDiscord),
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
discordGuildIconUrl: meta.discordGuildIconUrl || null,
discordChannelId: meta.discordChannelId || null,
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
text,
tts: meta.tts || null,
};
}
function pushHistory(message) {
history.push(message);
if (history.length > MAX_HISTORY) {
history.shift();
}
}
function broadcastMessage(message) {
pushHistory(message);
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
}
@@ -109,7 +127,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
cb({ error: 'Slow down' });
return;
}
if (clean.length > 256) {
if (clean.length > 150) {
cb({ error: 'Message too long' });
return;
}
@@ -121,10 +139,10 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
// cb({ error: 'Duplicate message' });
// return;
// }
if (isKeymash(clean)) {
cb({ error: 'Message looks like spam' });
return;
}
// if (isKeymash(clean)) {
// cb({ error: 'Message looks like spam' });
// return;
// }
const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
@@ -140,7 +158,7 @@ function maybeSpeak(socket, message, ttsOptions) {
const audio = record?.meta?.audio || {};
const ttsEnabled = Boolean(audio.ttsEnabled);
if (!ttsEnabled) return;
if (!roverManager.canDrive(message.roverId, socket)) return;
// if (!roverManager.canDrive(message.roverId, socket)) return;
try {
issueCommand(message.roverId, {
type: 'tts',
@@ -158,7 +176,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 clean = normalized.trim();
if (!clean || clean.length > 256) {
@@ -175,6 +205,13 @@ function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', rover
role,
roverId,
fromDiscord: true,
discordGuildId,
discordGuildName,
discordGuildIconUrl,
discordChannelId,
discordUserId,
discordUserName,
discordUserAvatarUrl,
});
logger.info('External chat message', { roverId, nickname });
broadcastMessage(message);
@@ -182,6 +219,7 @@ function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', rover
}
io.on('connection', (socket) => {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
});
+44
View File
@@ -1,9 +1,12 @@
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const roverManager = require('./roverManager');
const { isAdmin } = require('./roleService');
const logger = require('../globals/logger').child('commandService');
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) {
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 = {
issueCommand,
handleAck,
getRecentDriveActivity,
setDriveCooldown,
};
io.on('connection', (socket) => {
@@ -47,6 +68,29 @@ io.on('connection', (socket) => {
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 });
logger.info('Queued command', socket.id, roverId, type);
try {
@@ -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) => {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
socket.data.lockdownTimer = setTimeout(() => {
if (!isLockdownAdmin(socket)) {
disconnectForLockdown(socket);
}
}, 10000);
socket.once('disconnect', () => clearLockdownTimer(socket));
disconnectForLockdown(socket);
}
});
@@ -0,0 +1,255 @@
const { spawn } = require('child_process');
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('previewTranscoder');
const { loadConfig } = require('../../helpers/configLoader');
const roverManager = require('../roverManager');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const events = new EventEmitter();
const config = loadConfig();
const mediaConfig = config.media || {};
const previewConfig = mediaConfig.preview || {};
const ENABLED = Boolean(previewConfig.enabled);
const PREVIEW_CODEC = String(previewConfig.codec || 'av1').toLowerCase();
const PREVIEW_TRANSPORT = String(previewConfig.transport || 'rtsp').toLowerCase();
const PREVIEW_FPS = Number(previewConfig.fps || 10);
const PREVIEW_WIDTH = Number(previewConfig.width || 640);
const ROOM_BITRATE_KBPS = Number(previewConfig.roomBitrateKbps || 200);
const ROVER_BITRATE_KBPS = Number(previewConfig.roverBitrateKbps || 350);
const PRESET = Number.isFinite(previewConfig.preset) ? String(previewConfig.preset) : '8';
const GOP_SECONDS = Number(previewConfig.gopSeconds || 2);
const FFMPEG_BIN = previewConfig.ffmpegBin || process.env.FFMPEG_BIN || 'ffmpeg';
const recorders = new Map(); // key -> { proc, source }
let syncTimer = null;
function encodeStreamId(streamId) {
return encodeURIComponent(streamId).replace(/%2F/g, '/');
}
function buildSrtReadUrl(streamId) {
return `srt://127.0.0.1:9000?streamid=read:${encodeStreamId(streamId)}`;
}
function buildSrtPublishUrl(streamId) {
const encoded = encodeStreamId(streamId);
return `srt://127.0.0.1:9000?streamid=#!::r=${encoded},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
}
function buildRtspPublishUrl(streamId) {
return `rtsp://127.0.0.1:8554/${streamId}`;
}
function sanitizeCodec(codec) {
return String(codec || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'av1';
}
function buildPreviewId(id, codec) {
return `${id}-preview-${sanitizeCodec(codec)}`;
}
function getRoomCameraStream(camera) {
if (camera.streamUrl) return camera.streamUrl;
const url = String(camera.url || '');
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
return url;
}
return null;
}
function listSources() {
const rooms = getRoomCameras()
.map((camera) => {
const streamUrl = getRoomCameraStream(camera);
if (!streamUrl) return null;
const id = String(camera.id);
return {
type: 'room',
id,
label: camera.name || id,
inputUrl: streamUrl,
outputId: `room/${buildPreviewId(id, PREVIEW_CODEC)}`,
bitrateKbps: ROOM_BITRATE_KBPS,
};
})
.filter(Boolean);
const rovers = roverManager.getRoster().map((rover) => {
const id = String(rover.id);
return {
type: 'rover',
id,
label: rover.name || id,
inputUrl: buildSrtReadUrl(id),
outputId: buildPreviewId(id, PREVIEW_CODEC),
bitrateKbps: ROVER_BITRATE_KBPS,
};
});
return [...rooms, ...rovers];
}
function buildKey(source) {
return `${source.type}:${source.id}:${source.outputId}`;
}
function buildArgs(source) {
const gop = Math.max(1, Math.round(GOP_SECONDS * PREVIEW_FPS));
const maxrate = Math.floor(source.bitrateKbps * 1.1);
const bufsize = Math.max(1, source.bitrateKbps * 2);
const codec = PREVIEW_CODEC === 'av1' ? 'libsvtav1' : 'libx264';
const extraCodecArgs =
codec === 'libx264'
? ['-tune', 'zerolatency', '-profile:v', 'baseline', '-sc_threshold', '0']
: [];
const outputUrl =
PREVIEW_TRANSPORT === 'rtsp' ? buildRtspPublishUrl(source.outputId) : buildSrtPublishUrl(source.outputId);
const outputArgs =
PREVIEW_TRANSPORT === 'rtsp'
? ['-f', 'rtsp', '-rtsp_transport', 'tcp']
: ['-f', 'mpegts'];
return {
outputUrl,
args: [
'-hide_banner',
'-loglevel',
'info',
'-fflags',
'nobuffer',
'-flags',
'low_delay',
'-i',
source.inputUrl,
'-an',
'-vf',
`fps=${PREVIEW_FPS},scale=${PREVIEW_WIDTH}:-1`,
'-c:v',
codec,
'-preset',
PRESET,
...extraCodecArgs,
'-g',
String(gop),
'-keyint_min',
String(gop),
'-b:v',
`${source.bitrateKbps}k`,
'-maxrate',
`${maxrate}k`,
'-bufsize',
`${bufsize}k`,
'-pix_fmt',
'yuv420p',
...outputArgs,
outputUrl,
],
};
}
function spawnRecorder(source) {
const key = buildKey(source);
if (recorders.has(key)) return;
const { args, outputUrl } = buildArgs(source);
logger.info('Preview transcoder starting', {
key,
transport: PREVIEW_TRANSPORT,
codec: PREVIEW_CODEC,
inputUrl: source.inputUrl,
outputUrl,
});
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
const stderrChunks = [];
let stderrSize = 0;
proc.stderr.on('data', (chunk) => {
if (!chunk || stderrSize > 8192) return;
stderrChunks.push(chunk);
stderrSize += chunk.length;
});
recorders.set(key, { proc, source });
proc.on('exit', (code, signal) => {
recorders.delete(key);
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
if (stderrBuffer && stderrBuffer.length) {
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
logger.warn('Preview transcoder stderr', {
key,
stderrBytes: stderrBuffer.length,
stderrPreview: preview || '<non-utf8>',
});
} else {
logger.warn('Preview transcoder stderr', { key, stderrBytes: 0 });
}
if (!ENABLED) return;
const delay = 2000;
logger.warn('Preview transcoder exited; restarting', { key, code, signal });
setTimeout(() => {
if (!recorders.has(key) && ENABLED) {
spawnRecorder(source);
}
}, delay);
});
events.emit('spawn', { key, source });
}
function stopRecorder(key) {
const entry = recorders.get(key);
if (!entry) return;
entry.proc.kill('SIGTERM');
recorders.delete(key);
events.emit('stop', { key, source: entry.source });
}
function syncRecorders() {
const sources = listSources();
const desiredKeys = new Set();
sources.forEach((source) => {
const key = buildKey(source);
desiredKeys.add(key);
if (!recorders.has(key)) {
spawnRecorder(source);
}
});
Array.from(recorders.keys()).forEach((key) => {
if (!desiredKeys.has(key)) {
stopRecorder(key);
}
});
logger.info('Preview transcoders synced', { total: desiredKeys.size });
}
function start() {
if (!ENABLED) {
logger.info('Preview transcoders disabled');
return;
}
syncRecorders();
if (!syncTimer) {
syncTimer = setInterval(syncRecorders, 10000);
}
}
function stop() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
}
Array.from(recorders.keys()).forEach((key) => stopRecorder(key));
}
roverManager.managerEvents.on('rover', () => {
if (ENABLED) {
syncRecorders();
}
});
roomCameraEvents.on('update', () => {
if (ENABLED) {
syncRecorders();
}
});
start();
module.exports = {
previewEvents: events,
syncPreviewTranscoders: syncRecorders,
stopPreviewTranscoders: stop,
};
+4 -3
View File
@@ -1,6 +1,7 @@
const EventEmitter = require('events');
const io = require('../globals/io');
const { sendAlert, COLORS } = require('./alertService');
const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#ff9800';
const { isAdmin, isLockdownAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
@@ -34,7 +35,7 @@ function setMode(nextMode, socket, options = {}) {
}
currentMode = nextMode;
sendAlert({
color: COLORS.info,
color: ALERT_COLOR,
title: 'Mode Changed',
message: `Server mode set to ${nextMode}`,
});
@@ -65,7 +66,7 @@ io.on('connection', (socket) => {
try {
setMode(mode, socket);
} catch (err) {
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message });
sendAlert({ color: ALERT_COLOR, title: 'Mode change failed', message: err.message });
}
});
});
+2 -1
View File
@@ -9,7 +9,8 @@ function sanitizeNickname(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
return trimmed.slice(0, 32);
const sanitized = trimmed.replace(/\*/g, 'nope');
return sanitized.slice(0, 32);
}
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);
return null;
}
if (!camera.url) {
logger.warn('Room camera missing url', { id, camera });
if (!camera.url && !camera.streamUrl && !camera.mjpegUrl) {
logger.warn('Room camera missing url/streamUrl', { id, camera });
return null;
}
return {
id: String(id),
name: camera.name || camera.id || String(id),
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 http = require('http');
const https = require('https');
const logger = require('../globals/logger').child('roomCameraSnapshot');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const POLL_INTERVAL_MS = 500; // 2 fps target
const POLL_INTERVAL_MS = 67;
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
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 = {}) {
const prev = cameraState.get(id) || {};
const next = { ...prev, ...updates };
if (next.ts != null) {
next.stale = Date.now() - next.ts > STALE_AFTER_MS || !!next.stale;
} else {
next.stale = true;
}
cameraState.set(id, next);
return next;
}
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) {
const { id, url } = camera;
const state = cameraState.get(id);
@@ -37,8 +159,8 @@ async function fetchSnapshot(camera) {
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const ts = Date.now();
const next = markState(id, { frame: buffer, ts, error: null, failures: 0 });
events.emit('frame', { id, buffer, ts, stale: !!next.stale });
markState(id, { frame: buffer, ts, error: null, failures: 0 });
events.emit('frame', { id, buffer, ts });
} catch (err) {
const failures = (state?.failures || 0) + 1;
markState(id, { error: err.message, failures });
@@ -55,26 +177,34 @@ function stopAll() {
clearInterval(pollTimer);
pollTimer = null;
}
Array.from(streamState.keys()).forEach((id) => stopStream(id));
cameraState.clear();
}
function startAll() {
stopAll();
pollTimer = setInterval(() => {
getRoomCameras().forEach((camera) => fetchSnapshot(camera));
}, POLL_INTERVAL_MS);
getRoomCameras().forEach((camera) => fetchSnapshot(camera));
logger.info('Started snapshot polling', { count: getRoomCameras().length });
const cameras = getRoomCameras();
const snapshotCameras = cameras.filter((camera) => !getStreamUrl(camera) && camera.url);
cameras.forEach((camera) => startStream(camera));
if (snapshotCameras.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) {
const state = cameraState.get(id);
if (!state) return null;
const stale = state.ts == null || Date.now() - state.ts > STALE_AFTER_MS;
return {
frame: state.frame || null,
ts: state.ts || null,
stale,
error: state.error || null,
};
}
+17 -4
View File
@@ -7,6 +7,7 @@ const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnap
const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 800;
function passesMode(socket) {
const mode = getMode();
@@ -27,6 +28,7 @@ function canViewRoomCamera(socket) {
const cameraSubscribers = 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(cameraId -> ts)
function addSubscription(socket, cameraId) {
if (!cameraSubscribers.has(cameraId)) {
@@ -82,13 +84,24 @@ function sendStatus(socket, 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);
if (!bucket || !buffer) return;
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
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) => {
const state = getRoomCameraState(cameraId);
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, {
ts: state?.ts || null,
stale: state?.stale ?? true,
error: state?.error || null,
});
});
@@ -150,5 +162,6 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => {
removeAllSubscriptions(socket.id);
subscribeBuckets.delete(socket.id);
lastSentBySocket.delete(socket.id);
});
});
@@ -1,20 +1,21 @@
const roverWSS = require('../globals/ws');
const logger = require('../globals/logger').child('roverConnection');
const roverManager = require('./roverManager');
const { sendAlert, COLORS } = require('./alertService');
const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#00bcd4';
const { handleAck } = require('./commandService');
function handleMessage(roverId, msg) {
switch (msg.type) {
case 'hello':
roverManager.upsertRover(msg, this);
sendAlert({ color: COLORS.info, title: 'Rover Connected', message: roverId });
sendAlert({ color: ALERT_COLOR, title: 'Rover Connected', message: roverId });
break;
case 'sensor':
roverManager.handleSensorFrame(roverId, msg);
break;
case 'event':
sendAlert({ color: COLORS.info, title: `${roverId} event`, message: msg.event });
sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
break;
default:
break;
@@ -40,7 +41,7 @@ roverWSS.on('connection', (ws) => {
});
roverManager.upsertRover(msg, ws);
roverManager.broadcastRoster();
sendAlert({ color: COLORS.success, title: 'Rover Online', message: roverId });
sendAlert({ color: ALERT_COLOR, title: 'Rover Online', message: roverId });
return;
}
if (!roverId) return;
@@ -49,14 +50,14 @@ roverWSS.on('connection', (ws) => {
} else if (msg.type === 'ack') {
handleAck(msg);
} 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', () => {
if (roverId) {
roverManager.removeRover(roverId);
sendAlert({ color: COLORS.warn, title: 'Rover Offline', message: roverId });
sendAlert({ color: ALERT_COLOR, title: 'Rover Offline', message: roverId });
}
});
});
+293 -9
View File
@@ -1,17 +1,28 @@
const EventEmitter = require('events');
const io = require('../globals/io');
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 { MODES, getMode } = require('./modeManager');
const { isAdmin, roleEvents } = require('./roleService');
const { publishEvent } = require('./eventBus');
const videoSessions = require('./videoSessions');
const rovers = new Map(); // roverId -> record
const socketToRovers = new Map(); // socketId -> Set(roverId)
const spectatorSockets = new Set();
const turnService = require('./turnService');
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) {
if (!rovers.has(id)) {
@@ -20,12 +31,15 @@ function ensureRecord(id) {
meta: null,
ws: null,
lastSensor: null,
docked: null,
lastBumpAt: null,
drivers: new Set(),
locked: false,
lockReason: null,
batteryState: null,
room: `rover:${id}`,
lastSeen: Date.now(),
lastMovementAt: Date.now(),
});
}
return rovers.get(id);
@@ -60,6 +74,7 @@ function removeRover(id) {
const record = rovers.get(id);
if (!record) return;
rovers.delete(id);
stopDockGuard(id);
turnService.cleanupRover(id);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
@@ -82,7 +97,7 @@ function lockRover(id, locked, options = {}) {
record.lockReason = reason;
if (!silent) {
sendAlert({
color: COLORS.warn,
color: ALERT_COLOR,
title: 'Rover Locked',
message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`,
});
@@ -96,7 +111,7 @@ function lockRover(id, locked, options = {}) {
record.locked = false;
record.lockReason = null;
if (!silent) {
sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` });
sendAlert({ color: ALERT_COLOR, title: 'Rover Unlocked', message: `${id} unlocked.` });
}
publishEvent({
source: 'roverManager',
@@ -119,6 +134,7 @@ function getRoster() {
media: record.meta?.media,
cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio,
nightVision: record.meta?.nightVision,
locked: record.locked,
lockReason: record.lockReason,
lastSeen: record.lastSeen,
@@ -169,12 +185,218 @@ function handleSensorFrame(roverId, frame) {
const decoded = parseSensorFrame(frame.data);
record.lastSensor = { raw: frame, 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,
frame,
sensors: decoded,
});
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) {
@@ -204,7 +426,7 @@ function requestControl(roverId, socket, options = {}) {
if (!allowUser && !isAdmin(socket)) {
throw new Error('Only admins can request control');
}
if (record.locked && !isAdmin(socket) && !allowUser) {
if (record.locked && !isAdmin(socket)) {
throw new Error('Rover locked');
}
const mode = getMode();
@@ -224,7 +446,7 @@ function requestControl(roverId, socket, options = {}) {
socket.emit('controlGranted', { roverId });
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
sendAlert({
color: COLORS.success,
color: ALERT_COLOR,
title: 'Control Granted',
message: `${socket.id} now driving ${roverId}`,
});
@@ -275,6 +497,41 @@ function getPrimaryRoverForSocket(socketId) {
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 = {
upsertRover,
removeRover,
@@ -311,17 +568,40 @@ io.on('connection', (socket) => {
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
try {
if (socket.data?.role === 'spectator') {
throw new Error('Spectators cannot drive');
}
const targetId = roverId || Array.from(rovers.keys())[0];
if (!targetId) {
throw new Error('No rovers available');
}
const previousJoined = getRoversForSocket(socket.id);
if (!isAdmin(socket)) {
const { ok, message } = canSwitchRover(socket, targetId);
if (!ok) {
throw new Error(message || 'Switch denied');
}
}
logger.info('Request control', socket.id, targetId, { force });
requestControl(targetId, socket, { force: Boolean(force) });
requestControl(targetId, socket, { force: Boolean(force), 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 });
cb({ success: true, roverId: targetId });
} catch (err) {
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 });
}
}
@@ -347,7 +627,7 @@ io.on('connection', (socket) => {
cb({ success: true });
} catch (err) {
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 });
}
}
@@ -377,6 +657,10 @@ io.on('connection', (socket) => {
socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => {
logger.info('Socket disconnecting', socket.id);
removeSocket(socket);
});
socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id);
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);
});
});
+24
View File
@@ -9,10 +9,17 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
const { getNickname, nicknameEvents } = require('./nicknameService');
const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader');
const { getCommunityGoal } = require('./communityGoalService');
const { subscribe } = require('./eventBus');
const discordInvite = loadConfig().discord?.invite || null;
const kofiLink = loadConfig().kofi?.link || null;
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;
let lastActivitySync = 0;
@@ -45,10 +52,17 @@ function buildSession(socket) {
turnQueues: getTurnQueues(),
roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(),
replay: getReplayState(),
replaySources: getReplaySources(),
health: getHealthSnapshot(),
communityGoal: getCommunityGoal(),
users,
discord: {
invite: discordInvite,
},
kofi: {
link: kofiLink,
},
};
}
@@ -156,6 +170,11 @@ homeAssistantEvents.on('status', () => {
syncAll();
});
replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients');
syncAll();
});
nicknameEvents.on('change', ({ socketId }) => {
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
if (socket) {
@@ -166,6 +185,11 @@ nicknameEvents.on('change', ({ socketId }) => {
}
});
subscribe('communityGoal.updated', () => {
logger.info('Community goal updated; syncing all clients');
syncAll();
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');
+37 -5
View File
@@ -1,6 +1,7 @@
const EventEmitter = require('events');
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 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 IDLE_TIMEOUT_MS = 7 * 1000;
const MAX_IDLE_SKIPS = 3;
const STALE_REAPER_MS = 5000;
const turnEvents = new EventEmitter();
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
@@ -39,6 +41,7 @@ function driverRemoved(roverId, socketId) {
}
idleDisarmed.delete(roverId);
if (queue.current === socketId) {
stopRover(roverId);
advanceTurn(roverId);
} else {
scheduleIdleTimer(roverId);
@@ -158,7 +161,7 @@ function handleIdleTimeout(roverId, expectedDriver) {
stopRover(roverId);
if (skips >= MAX_IDLE_SKIPS) {
sendAlert({
color: COLORS.error,
color: ALERT_COLOR,
title: 'Driver removed',
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
});
@@ -166,7 +169,7 @@ function handleIdleTimeout(roverId, expectedDriver) {
return;
}
sendAlert({
color: COLORS.warn,
color: ALERT_COLOR,
title: 'Turn skipped',
message: `${expectedDriver} skipped on ${roverId} (idle ${skips}/${MAX_IDLE_SKIPS})`,
});
@@ -177,6 +180,7 @@ function advanceTurn(roverId) {
const queue = driverQueues.get(roverId);
if (!queue) return;
if (queue.queue.length === 0) {
stopRover(roverId);
clearTimeout(queue.timer);
setActiveDriver(roverId, null);
turnDeadlines.delete(roverId);
@@ -205,7 +209,7 @@ function advanceTurn(roverId) {
queue.current = queue.queue[nextIdx];
setActiveDriver(roverId, queue.current);
idleDisarmed.set(roverId, false);
sendAlert({ color: COLORS.info, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` });
sendAlert({ color: ALERT_COLOR, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` });
stopRover(roverId);
scheduleNextTurn(roverId);
scheduleIdleTimer(roverId);
@@ -265,16 +269,44 @@ function removeDriverCompletely(roverId, socketId) {
function recordActivity(roverId, socketId) {
const queue = driverQueues.get(roverId);
if (!queue || queue.current !== socketId) return;
if (idleDisarmed.get(roverId)) return;
idleDisarmed.set(roverId, true);
const hadDeadline = idleDeadlines.has(roverId);
clearTimeout(idleTimers.get(roverId));
idleDeadlines.delete(roverId);
turnEvents.emit('queue', { roverId, reason: 'activity' });
if (hadDeadline) {
turnEvents.emit('queue', { roverId, reason: 'activity' });
}
}
modeEvents.on('change', (mode) => {
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 = {
driverAdded,
driverRemoved,
+13 -2
View File
@@ -46,7 +46,11 @@ function extractStreamInfo(path) {
const remaining = segments.slice(start, end);
if (remaining.length === 1) {
const rawId = remaining[0] || '';
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
let baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
const previewMatch = baseId.match(/^(.*)-preview-[a-z0-9]+$/);
if (previewMatch) {
baseId = previewMatch[1];
}
return { type: 'rover', id: rawId, baseId };
}
if (remaining.length === 2 && remaining[0] === 'room') {
@@ -74,9 +78,16 @@ app.post('/mediamtx/auth', (req, res) => {
const body = req.body || {};
const path = (body.path || '').replace(/^\//, '');
const sessionId = body.user;
const action = (body.action || '').toLowerCase();
const protocol = (body.protocol || '').toLowerCase();
const ip = body.ip || req.ip || '';
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, ip });
if (action === 'read' && protocol === 'srt' && streamInfo?.id) {
return res.status(200).end();
}
if (!sessionId || !streamInfo?.id) {
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
+40 -7
View File
@@ -4,6 +4,7 @@ const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const videoSessions = require('./videoSessions');
const roverManager = require('./roverManager');
const { getRoomCamera } = require('./roomCameraService');
const { loadConfig } = require('../helpers/configLoader');
const config = loadConfig();
@@ -36,6 +37,17 @@ function buildWhepUrlForSource(source) {
return `${cleanBase}/${segments.join('/')}/whep`;
}
function sanitizeCodec(codec) {
return String(codec || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
function buildPreviewId(id, codec) {
const cleanCodec = sanitizeCodec(codec) || 'av1';
return `${id}-preview-${cleanCodec}`;
}
function passesMode(socket) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
@@ -65,14 +77,21 @@ function canViewRoomCamera(socket) {
function normalizeRequest(payload = {}) {
if (!payload) return null;
const preview = Boolean(payload.preview || payload.mode === 'preview');
const codec = payload.codec ? String(payload.codec) : null;
if (payload.type && payload.id) {
return { type: payload.type, id: String(payload.id) };
return {
type: payload.type,
id: String(payload.id),
preview,
codec,
};
}
if (payload.roverId) {
return { type: 'rover', id: String(payload.roverId) };
return { type: 'rover', id: String(payload.roverId), preview, codec };
}
if (payload.roomCameraId) {
return { type: 'room', id: String(payload.roomCameraId) };
return { type: 'room', id: String(payload.roomCameraId), preview, codec };
}
return null;
}
@@ -93,16 +112,30 @@ io.on('connection', (socket) => {
throw new Error('Not authorized for video');
}
} else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed');
if (!target.preview) {
throw new Error('Room cameras now use the snapshot feed');
}
if (!getRoomCamera(target.id)) {
throw new Error('Room camera not found');
}
} else {
throw new Error('Unsupported video source');
}
const url = buildWhepUrlForSource(target);
const requestId = target.preview ? buildPreviewId(target.id, target.codec) : target.id;
const requestTarget = { ...target, id: requestId };
const url = buildWhepUrlForSource(requestTarget);
if (!url) {
throw new Error('Server video base URL missing');
}
const sessionId = videoSessions.createSession(socket, target);
cb({ url, token: sessionId, type: target.type, id: target.id });
const sessionId = videoSessions.createSession(socket, requestTarget);
cb({
url,
token: sessionId,
type: requestTarget.type,
id: requestTarget.id,
preview: Boolean(target.preview),
codec: target.codec || null,
});
} catch (err) {
logger.warn('video request failed: %s', err.message);
cb({ error: err.message });
+1 -1
View File
@@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
# dist
dist-ssr
*.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 {
padding: 2em;
}
+157 -58
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import TelemetryPanel from './components/TelemetryPanel.jsx';
import DrivePanel from './components/DrivePanel.jsx';
import ControlSummary, { RoverRosterPanel } from './components/ControlSummary.jsx';
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
import AlertFeed from './components/AlertFeed.jsx';
import AdminPanel from './components/AdminPanel.jsx';
import MobileControls, {
MobileLandscapeAuxColumn,
MobileLandscapeControlColumn,
@@ -11,17 +11,22 @@ import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from
import { SettingsProvider } from './settings/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx';
import AuthPanel from './components/AuthPanel.jsx';
import DriverVideoPanel from './components/DriverVideoPanel.jsx';
import RightPaneTabs from './components/RightPaneTabs.jsx';
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
import SessionSnapshot from './components/SessionSnapshot.jsx';
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
import TurnAlertListener from './components/TurnAlertListener.jsx';
import UserListPanel from './components/UserListPanel.jsx';
import ChatPanel from './components/ChatPanel.jsx';
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
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';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
@@ -53,7 +58,7 @@ function useLayoutMode() {
return mode;
}
function DesktopLayout({ layout }) {
function DesktopLayout({ layout, onOpenHelpOverlay }) {
return (
<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">
@@ -69,57 +74,99 @@ function DesktopLayout({ layout }) {
<LogPanel />
</div>
<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 /> */}
</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 />
<UserListPanel />
</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 (
<div className="flex flex-col gap-0.5">
<DriverVideoPanel layoutFormat='mobile'/>
<DriverVideoPanel layoutFormat="mobile-portrait" />
<MobileControls />
<ChatPanel />
<UserListPanel />
<DrivePanel />
<TelemetryPanel />
<AuthPanel />
<AdminPanel />
<RoomCameraPanel panelId="mobile-portrait-room" />
<HomeAssistantControls />
<LogPanel />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
<RoverRosterPanel />
</div>
{/* <ControlSummary /> */}
<MobileFeatureTabs
layout="mobile-portrait"
onOpenHelpOverlay={onOpenHelpOverlay}
roomPanelId="mobile-portrait-room"
/>
</div>
);
}
function MobileLandscapeLayout() {
function MobileLandscapeLayout({ onOpenHelpOverlay }) {
return (
<div className="flex flex-col gap-0.5">
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
<MobileLandscapeAuxColumn />
<div>
<DriverVideoPanel layoutFormat='mobile'/>
<TelemetryPanel />
<DriverVideoPanel layoutFormat="mobile-landscape" />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
<RoverRosterPanel />
</div>
{/* <TelemetryPanel /> */}
</div>
<MobileLandscapeControlColumn />
</section>
<div className="flex flex-col gap-0.5 pb-0.5">
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
<div className="flex flex-col gap-0.5">
{/* <AuthPanel /> */}
{/* <AdminPanel /> */}
<UserListPanel />
</div>
<div className="flex flex-col gap-0.5">
{/* <LogPanel /> */}
<ChatPanel />
</div>
</section>
<RoomCameraPanel panelId="mobile-landscape-room" />
<HomeAssistantControls />
{/* <DrivePanel /> */}
<MobileFeatureTabs
layout="mobile-landscape"
onOpenHelpOverlay={onOpenHelpOverlay}
roomPanelId="mobile-landscape-room"
showTelemetry={false}
/>
</div>
</div>
);
@@ -128,36 +175,88 @@ function MobileLandscapeLayout() {
function App() {
const layout = useLayoutMode();
const isDesktop = layout === 'desktop';
const { visible: fullscreenVisible, mode: fullscreenMode, enterFullscreen, dismiss } = useFullscreenPrompt(layout);
const renderedLayout =
isDesktop
? <DesktopLayout layout={layout} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout />
: <MobilePortraitLayout />;
const fullscreen = useFullscreenPrompt(layout);
return (
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
<SettingsProvider>
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
{renderedLayout}
</main>
<AlertFeed />
<TurnAlertListener />
<ModeGateOverlay />
<FullscreenPrompt
visible={fullscreenVisible}
mode={fullscreenMode}
onEnterFullscreen={enterFullscreen}
onDismiss={dismiss}
/>
</ControlSystemProvider>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
</SettingsProvider>
</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 />
<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;
+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
+112 -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 RoverRoster from './RoverRoster.jsx';
@@ -10,9 +10,13 @@ const MODES = [
];
export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession();
const { session, lockRover, setMode, requestControl, setCommunityGoal } = useSession();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
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 =
session?.role === 'admin' ||
@@ -47,6 +51,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 map = {};
roster.forEach((rover) => {
@@ -69,6 +93,29 @@ export default function AdminPanel() {
))}
</select>
</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
roster={roster}
@@ -87,6 +134,69 @@ export default function AdminPanel() {
</div>
)}
/>
<ReplaySnapshotHealth health={health} />
</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.25 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.25 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.25 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>
);
}
+31 -11
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import ChatMessageRow from './ChatMessageRow.jsx';
const LIFETIME_MS = 5000;
const LIFETIME_MS = 3000;
const DEFAULT_COLOR = '#2196f3';
function buildKey(alert) {
if (alert.id) return alert.id;
@@ -12,7 +14,7 @@ function buildKey(alert) {
export default function AlertFeed() {
const { alerts } = useSession();
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(() => {
if (!latest.length) return undefined;
@@ -38,17 +40,35 @@ 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 }) {
const colorClasses =
alert.color === 'error'
? 'bg-red-500/30 text-red-100'
: alert.color === 'warn'
? 'bg-amber-500/30 text-amber-100'
: 'bg-emerald-500/30 text-emerald-100';
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
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 (
<div className={`pointer-events-auto px-0.5 py-0.5 text-[0.85rem] ${colorClasses}`}>
<p className="text-xs text-slate-200">{alert.title || 'Alert'}</p>
<p className="text-sm text-white">{alert.message}</p>
<div
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"
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>
);
}
+99
View File
@@ -0,0 +1,99 @@
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 default function ChatMessageRow({ message }) {
const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null;
return (
<div
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
isAdmin
? 'border border-amber-400/30'
: message.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
{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]">rover {message.roverId}</span>
)}
<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>
);
}
+49 -64
View File
@@ -1,29 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
function roleColors(role, fromDiscord) {
if (fromDiscord) return 'text-indigo-200';
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatTime(ts) {
const date = new Date(ts);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function displayName(message) {
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
}
import { useSettingsNamespace } from '../settings/index.js';
import ChatMessageRow from './ChatMessageRow.jsx';
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
@@ -31,12 +10,16 @@ const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
const { session } = useSession();
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
const {
value: ttsSettings,
save: saveTtsSettings,
} = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(false);
const [engine, setEngine] = useState('flite');
const [voice, setVoice] = useState('rms');
const [pitch, setPitch] = useState(50);
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
const canChat = session?.role !== 'spectator';
const listRef = useRef(null);
const currentRoverId = session?.assignment?.roverId || null;
@@ -54,6 +37,15 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [sorted]);
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(() => {
if (ttsSupported) {
setSpeak(true);
@@ -97,52 +89,29 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
{sorted.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p>
) : (
sorted.map((msg) => {
const isAdmin =
msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin';
return (
<div
key={msg.id}
className={`surface-muted text-sm flex items-start gap-1 ${
isAdmin
? 'border border-amber-400/30'
: msg.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
<span className="text-[0.75rem] text-slate-400">{formatTime(msg.ts)}</span>
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role, msg.fromDiscord)}`}>
{displayName(msg)}
</span>
{msg.fromDiscord && (
<span className="rounded bg-indigo-500/30 px-1 text-[0.7rem] text-indigo-100">
Discord
</span>
)}
{msg.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {msg.roverId}</span>
)}
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{msg.text}</span>
</div>
);
})
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
)}
</div>
{!hideInput && (
<form className="flex gap-0.5" onSubmit={handleSend}>
<form className="flex flex-wrap items-start gap-0.5" onSubmit={handleSend}>
<input
className="field-input flex-1"
className="field-input flex-1 min-w-[10rem]"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onFocus={onInputFocus}
onBlur={onInputBlur}
ref={(el) => registerInputRef(el)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
}
}}
ref={(el) => registerInputRef(el, { target: 'panel' })}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat}
/>
{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">
<input
type="checkbox"
@@ -154,7 +123,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
</label>
<select
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"
>
<option value="flite">flite</option>
@@ -163,7 +136,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
{engine === 'flite' ? (
<select
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"
>
{FLITE_VOICES.map((v) => (
@@ -175,7 +152,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
) : (
<select
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"
>
{ESPEAK_PITCHES.map((p) => (
@@ -187,7 +168,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
)}
</div>
)}
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
<button
type="submit"
disabled={!canChat || sending}
className="button-dark disabled:opacity-50 self-start"
>
{sending ? '...' : 'Send'}
</button>
</form>
@@ -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>
);
}
+168
View File
@@ -0,0 +1,168 @@
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 ReplaySourcesPanel from './ReplaySourcesPanel.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({ showRoster = true }) {
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-1 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] md:min-h-[22rem]">
<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.75">
<div className="row-span-2">
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
</div>
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
</div>
</div>
{showRoster ? (
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<div>
<ReplaySourcesPanel />
</div>
<RoverRosterPanel />
</div>
) : null}
</section>
);
}
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.25 flex items-center justify-between text-[0.7rem] text-slate-400">
<span className="flex items-center gap-0.25">
{downLabel ? <KeyPill label={downLabel} /> : null}
{formatDegrees(min)}
</span>
<span className="flex items-center gap-0.25">
{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)}°`;
}
+2 -2
View File
@@ -11,8 +11,8 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
<a
href={discordInvite}
target="_blank"
rel="noopener noreferrer"npm
className="inline-flex items-center px-3 py-2 text-white rainbow-animate-bg transition justify-center"
rel="noopener noreferrer"
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"
// 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"
>
+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.25">
<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.25 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.25 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.35 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.35 h-px bg-white/10" /> : null}
</div>
))}
</div>
);
}
+4 -3
View File
@@ -35,16 +35,16 @@ export default function DrivePanel() {
</div> */}
<div className="space-y-0.5">
<ActionCard
title="Start Driving"
title="Startfdsafsdfakjfdhlkfshlkjss Driving"
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"
onClick={handleStartDrive}
disabled={!roverId}
/>
<ActionCard
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={[
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
@@ -55,6 +55,7 @@ export default function DrivePanel() {
/>
</div>
{/* <DiscordInviteButton /> */}
{/* kjfhdljhdsaflkjf */}
</section>
);
}
+116 -3
View File
@@ -1,23 +1,94 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
import { useControlSystem } from '../controls/index.js';
import VideoTile from './VideoTile.jsx';
import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const { session } = useSession();
const {
state: { song, lastControlIntentAt },
} = useControlSystem();
const [now, setNow] = useState(() => Date.now());
const [turnCueVisible, setTurnCueVisible] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const lastTurnRef = useRef({ active: false, roverId: null });
useEffect(() => {
if (session?.mode !== 'turns') {
return undefined;
}
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [session?.mode]);
const roverId = session?.assignment?.roverId;
const rosterEntry =
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
const turnInfo = roverId ? session?.turnQueues?.[roverId] : null;
const socketId = session?.socketId || null;
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) {
return queue[0] || null;
}
return queue[(idx + 1) % queue.length] || null;
}, [turnInfo?.queue, turnInfo?.current]);
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isPreSwitchWindow =
session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const shouldShowVideo = session?.mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds =
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
: null;
const turnTimerText = isActiveDriver
? turnSeconds != null
? `${turnSeconds}s left`
: null
: isNextDriver && turnSeconds != null
? `Your turn in ${turnSeconds}s`
: null;
const entries = roverId
? [
{ type: 'rover', id: roverId, key: roverId },
...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []),
...(hasAudio ? [{ type: 'rover', id: `${roverId}-audio`, key: `${roverId}-audio` }] : []),
]
: [];
const sources = useVideoRequests(entries);
const info = roverId ? sources[roverId] : null;
const info = roverId && shouldShowVideo ? sources[roverId] : null;
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
const av1Supported = supportsAv1WebRtc();
const previewEntries = roverId
? [
{
type: 'rover',
id: roverId,
key: `rover:${roverId}:preview:av1`,
preview: true,
codec: 'av1',
},
]
: [];
const previewSources = useVideoRequests(previewEntries, { enabled: Boolean(roverId) && av1Supported });
const previewSession = roverId ? previewSources[`rover:${roverId}:preview:av1`] || null : null;
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
enabled: Boolean(roverId),
version: session?.mode,
});
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
const frame = useTelemetryFrame(roverId);
const batteryRecord =
roverId && session?.roster
@@ -27,16 +98,58 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
useEffect(() => {
if (session?.mode !== 'turns') {
setTurnCueVisible(false);
setTurnCueStartAt(null);
lastTurnRef.current = { active: false, roverId: null };
return;
}
const lastTurn = lastTurnRef.current;
const becameActive = isActiveDriver && !lastTurn.active;
const roverChanged = isActiveDriver && roverId && roverId !== lastTurn.roverId;
if (becameActive || roverChanged) {
setTurnCueVisible(true);
setTurnCueStartAt(Date.now());
} else if (!isActiveDriver && lastTurn.active) {
setTurnCueVisible(false);
setTurnCueStartAt(null);
}
lastTurnRef.current = { active: isActiveDriver, roverId };
}, [isActiveDriver, roverId, session?.mode]);
useEffect(() => {
if (!turnCueVisible || !turnCueStartAt) return;
if (lastControlIntentAt > turnCueStartAt) {
setTurnCueVisible(false);
}
}, [lastControlIntentAt, turnCueStartAt, turnCueVisible]);
return (
<section className="panel">
{roverId ? (
<VideoTile
sessionInfo={info}
sessionInfo={shouldShowVideo ? info : previewSession?.url ? previewSession : null}
videoMode={shouldShowVideo ? 'whep' : previewSession?.url ? 'whep' : 'snapshot'}
snapshotFeed={snapshotFeed}
audioSessionInfo={audioInfo}
label={roverLabel}
telemetryFrame={frame}
batteryConfig={batteryConfig}
layoutFormat={layoutFormat}
songNote={song?.note}
qualityNotice={
!shouldShowVideo
? previewSession?.url
? 'Preview feed (AV1) until your turn.'
: 'Preview feed (snapshots) until your turn.'
: null
}
showTurnCue={turnCueVisible}
turnTimerText={turnTimerText}
turnSeconds={turnSeconds}
isActiveDriver={isActiveDriver}
idleSkipSeconds={idleSkipSeconds}
/>
) : (
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
+204
View File
@@ -0,0 +1,204 @@
import { useMemo } from 'react';
import { formatKeyLabel } from '../controls/keymapUtils.js';
import { getHelpContent } from '../help/content.js';
function KeyPill({ actionId, keymap }) {
const value = keymap?.[actionId]?.[0] ?? '';
return (
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
{formatKeyLabel(value)}
</span>
);
}
function renderSegments(segments, keymap) {
return segments.map((segment, idx) => {
if (typeof segment === 'string') {
return <span key={`text-${idx}`}>{segment}</span>;
}
if (segment?.action) {
return <KeyPill key={`pill-${segment.action}-${idx}`} actionId={segment.action} keymap={keymap} />;
}
if (segment?.text) {
return <span key={`span-${idx}`}>{segment.text}</span>;
}
return null;
});
}
function renderLine(line, keymap, idx) {
if (typeof line === 'string') return line;
if (Array.isArray(line?.segments)) return renderSegments(line.segments, keymap);
if (line && typeof line === 'object') {
// Fallback: render any stray object as text if possible
if (typeof line.text === 'string') return line.text;
if (Array.isArray(line)) return renderSegments(line, keymap);
}
return String(line ?? '');
}
function Hero({ hero, keymap }) {
if (!hero) return null;
return (
<div className="surface space-y-0.25 px-0.5 py-0.5">
<div className="flex flex-wrap items-center justify-between gap-0.5">
<div>
<p className="text-sm font-semibold text-white">{hero.title}</p>
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
</div>
{hero.chips && (
<div className="flex flex-wrap gap-0.25 text-[0.7rem] text-slate-200">
{hero.chips.map((chip) => (
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
{chip}
</span>
))}
</div>
)}
</div>
{hero.bullets && (
<ul className="space-y-0.25 text-[0.85rem] text-slate-200">
{hero.bullets.map((line, idx) => {
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
return (
<li key={key} className="surface-muted px-0.5 py-0.25">
{renderLine(line, keymap, idx)}
</li>
);
})}
</ul>
)}
</div>
);
}
function ListBlock({ block, keymap }) {
return (
<div className="space-y-0.25">
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
{block.items.map((item, idx) => {
const key = `item-${idx}`;
return (
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.25 px-0.5 py-0.25">
{renderLine(item, keymap, idx)}
</li>
);
})}
</ul>
</div>
);
}
function CalloutBlock({ block }) {
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
return (
<div className={`surface space-y-0.25 border ${toneClass} px-0.5 py-0.5`}>
<p className="text-xs font-semibold text-slate-100">{block.title}</p>
{block.body && (
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
{block.body.map((line, idx) => (
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
{renderLine(line, {})}
</li>
))}
</ul>
)}
</div>
);
}
function KeyboardGroup({ group, keymap }) {
return (
<div className="space-y-0.25 surface">
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
<div className="space-y-0.25 px-0.5 pb-0.25">
{group.items.map((item) => (
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
<span className="text-slate-200">{item.label}</span>
<KeyPill actionId={item.action} keymap={keymap} />
</div>
))}
</div>
</div>
);
}
function KeyboardBlock({ block, keymap }) {
if (!block) return null;
return (
<div className="space-y-0.25">
<div className="flex items-center justify-between text-xs text-slate-200">
<span className="font-semibold">{block.title}</span>
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-0.5">
{block.groups?.map((group) => (
<KeyboardGroup key={group.id} group={group} keymap={keymap} />
))}
</div>
</div>
);
}
function GamepadBlock({ block }) {
if (!block) return null;
return (
<div className="space-y-0.25">
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
{block.items?.map((line, idx) => (
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
{renderLine(line, {})}
</li>
))}
</ul>
</div>
);
}
function BlockRenderer({ block, keymap }) {
if (!block) return null;
switch (block.type) {
case 'list':
return <ListBlock block={block} keymap={keymap} />;
case 'callout':
return <CalloutBlock block={block} />;
case 'keyboard':
return <KeyboardBlock block={block} keymap={keymap} />;
case 'gamepad':
return <GamepadBlock block={block} />;
default:
return null;
}
}
export function HelpContentView({ layout, keymap }) {
const content = useMemo(() => getHelpContent(layout), [layout]);
const bindings = keymap || {};
const mainBlocks = content.main || [];
const asideBlocks = content.aside || [];
return (
<div className="space-y-0.5">
<Hero hero={content.hero} keymap={bindings} />
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))' }}
>
<div className="space-y-0.5">
{mainBlocks.map((block, idx) => (
<BlockRenderer key={block.title || idx} block={block} keymap={bindings} />
))}
</div>
<div className="space-y-0.5">
{asideBlocks.map((block, idx) => (
<BlockRenderer key={block.title || idx} block={block} keymap={bindings} />
))}
</div>
</div>
</div>
);
}
export default HelpContentView;
+44
View File
@@ -0,0 +1,44 @@
import { useControlSystem } from '../controls/index.js';
import HelpContentView from './HelpContentView.jsx';
export default function HelpOverlay({ visible, layout, onClose, showOnLoad, onToggleShowOnLoad }) {
if (!visible) return null;
const { state } = useControlSystem();
const handleCheckbox = (event) => {
const keepShowing = !event.target.checked;
onToggleShowOnLoad?.(keepShowing);
};
return (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/80 px-0.5 py-0.5">
<div className="pointer-events-auto surface w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.25 text-sm text-slate-200">
<span className="font-semibold">Help & controls</span>
<div className="flex items-center gap-0.5 text-[0.8rem] text-slate-300">
<label className="flex items-center gap-0.25">
<input
type="checkbox"
checked={!showOnLoad}
onChange={handleCheckbox}
className="accent-cyan-500"
/>
<span>Don&apos;t show again</span>
</label>
<button
type="button"
onClick={onClose}
className="button-dark px-2 py-0.25 text-[0.8rem]"
>
Close
</button>
</div>
</div>
<div className="max-h-[85vh] overflow-y-auto p-0.5">
<HelpContentView layout={layout} keymap={state?.keymap || {}} />
</div>
</div>
</div>
);
}
+18 -28
View File
@@ -1,32 +1,22 @@
export default function HelpPanel({ layout }) {
const presets = {
desktop: [
'Use WASD + Shift to drive. Video must stay focused.',
'Macros on the left prep the rover before moving.',
'Switch tabs on the right for telemetry, room, and advanced tools.',
],
'mobile-portrait': [
'Keep joystick centered when not driving.',
'Auxiliary buttons sit beside the joystick use them carefully.',
'Scroll to access drive/telemetry if screen space is limited.',
],
'mobile-landscape': [
'Joystick and video are side by side for game-style control.',
'Rotate back to portrait if you need admin controls quickly.',
'Tabs are desktop-only; mobile shows sections stacked.',
],
};
const tips = presets[layout] || presets.desktop;
import { useControlSystem } from '../controls/index.js';
import HelpContentView from './HelpContentView.jsx';
export default function HelpPanel({ layout, onOpenOverlay }) {
const { state } = useControlSystem();
return (
<section className="panel-section space-y-0.5 text-base">
<p className="text-sm text-slate-400">Help</p>
<ul className="space-y-0.5 text-sm">
{tips.map((tip) => (
<li key={tip} className="surface text-slate-200">
{tip}
</li>
))}
</ul>
<section className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Help</span>
<button
type="button"
onClick={onOpenOverlay}
className="button-dark px-1 py-0.25 text-[0.75rem]"
>
Open full help
</button>
</div>
<HelpContentView layout={layout} keymap={state?.keymap || {}} />
</section>
);
}
+25 -3
View File
@@ -1,5 +1,7 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js';
function StatusBadge({ label, tone = 'muted' }) {
const styles =
@@ -32,12 +34,12 @@ function EntityRow({ entity, connected, onToggle }) {
type="button"
onClick={() => onToggle(entity.id)}
disabled={disableToggle}
className={`flex w-full items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
className={`flex min-w-[12rem] flex-1 items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
>
<div className="min-w-0">
<div className="flex items-center gap-1 text-sm">
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
<StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} />
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */}
</div>
<div className="flex items-center gap-1 text-xs text-slate-400">
<StatusBadge label={statusLabel} tone={statusTone} />
@@ -50,9 +52,14 @@ function EntityRow({ entity, connected, onToggle }) {
}
export default function HomeAssistantControls() {
const {
state: { keymap },
} = useControlSystem();
const { session, homeAssistantToggle } = useSession();
const ha = session?.homeAssistant;
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
if (!ha?.enabled) {
return (
@@ -80,10 +87,20 @@ export default function HomeAssistantControls() {
<div className="flex items-center gap-1">
<p>Light Controls</p>
<span className="text-xs text-slate-500">{entities.length}</span>
<div className="flex items-center gap-1 text-xs text-slate-300 background-black">
<span className="flex items-center gap-0.5">
<span>On</span>
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
</span>
<span className="flex items-center gap-0.5">
<span>Off</span>
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
</span>
</div>
</div>
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
</header>
<div className="space-y-0.5">
<div className="flex flex-wrap gap-0.5">
{entities.map((entity) => (
<EntityRow
key={entity.id}
@@ -96,3 +113,8 @@ export default function HomeAssistantControls() {
</section>
);
}
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
+159
View File
@@ -2,6 +2,8 @@ import { useMemo, useState, useEffect, useCallback } from 'react';
import { useControlSystem } from '../controls/index.js';
import { DEFAULT_KEYMAP } from '../controls/constants.js';
import { canonicalizeKeyInput, formatKeyLabel } from '../controls/keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js';
import { INPUT_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
const KEY_ACTIONS = [
{ id: 'driveForward', label: 'Drive Forward', group: 'Driving' },
@@ -19,9 +21,14 @@ const KEY_ACTIONS = [
{ id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' },
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
{ id: 'songNoteUp', label: 'Song Note Up', group: 'Audio' },
{ id: 'songNoteDown', label: 'Song Note Down', group: 'Audio' },
{ id: 'homeAssistantOn', label: 'Room Controls On (Cycle)', group: 'Room Controls' },
{ id: 'homeAssistantOff', label: 'Room Controls Off (Cycle)', group: 'Room Controls' },
];
function groupActions(actions) {
@@ -32,6 +39,43 @@ function groupActions(actions) {
}, {});
}
function clampSpeed(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(500, num));
}
const TILT_INTERVAL_MIN = 5;
const TILT_INTERVAL_MAX = 500;
const TILT_SPEED_MIN = 1;
const TILT_SPEED_MAX = 100;
function clampTiltInterval(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(TILT_INTERVAL_MIN, Math.min(TILT_INTERVAL_MAX, num));
}
function clampTiltSpeed(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(TILT_SPEED_MIN, Math.min(TILT_SPEED_MAX, num));
}
function mapTiltSpeedToInterval(speed) {
const clampedSpeed = clampTiltSpeed(speed, speed);
const ratio = (clampedSpeed - TILT_SPEED_MIN) / (TILT_SPEED_MAX - TILT_SPEED_MIN);
const interval = TILT_INTERVAL_MAX - ratio * (TILT_INTERVAL_MAX - TILT_INTERVAL_MIN);
return Math.round(interval);
}
function mapTiltIntervalToSpeed(interval) {
const clampedInterval = clampTiltInterval(interval, interval);
const ratio = (TILT_INTERVAL_MAX - clampedInterval) / (TILT_INTERVAL_MAX - TILT_INTERVAL_MIN);
const speed = TILT_SPEED_MIN + ratio * (TILT_SPEED_MAX - TILT_SPEED_MIN);
return Math.round(speed);
}
function useKeyCapture(onCapture) {
const [active, setActive] = useState(null);
@@ -56,11 +100,59 @@ function useKeyCapture(onCapture) {
return { active, startCapture: setActive, cancel: () => setActive(null) };
}
function SpeedField({ label, description, value, onChange, min = 0, max = 500, step = 5 }) {
return (
<label className="surface-muted block p-0.5">
<div className="flex items-center justify-between text-xs text-slate-300">
<span className="font-semibold text-slate-100">{label}</span>
<input
type="number"
min={min}
max={max}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-16 rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.75rem] font-mono text-slate-100"
/>
</div>
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
<div className="mt-0.5 flex items-center gap-0.5">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-2 flex-1 accent-emerald-400"
/>
<span className="w-12 text-right text-[0.75rem] font-mono text-slate-400">{value}</span>
</div>
</label>
);
}
export default function KeymapSettings() {
const {
state: { keymap },
actions: { updateKeyBinding, resetKeyBindings },
} = useControlSystem();
const { value: inputSettings, save: saveInputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
const keyboardSettings = {
...INPUT_SETTINGS_DEFAULTS.keyboard,
...(inputSettings.keyboard ?? {}),
};
const tiltSpeed = useMemo(() => {
if (typeof keyboardSettings.tiltSpeed === 'number') {
return clampTiltSpeed(keyboardSettings.tiltSpeed, INPUT_SETTINGS_DEFAULTS.keyboard.tiltSpeed);
}
if (typeof keyboardSettings.tiltIntervalMs === 'number') {
return clampTiltSpeed(
mapTiltIntervalToSpeed(keyboardSettings.tiltIntervalMs),
INPUT_SETTINGS_DEFAULTS.keyboard.tiltSpeed,
);
}
return INPUT_SETTINGS_DEFAULTS.keyboard.tiltSpeed;
}, [keyboardSettings.tiltIntervalMs, keyboardSettings.tiltSpeed]);
const grouped = useMemo(() => groupActions(KEY_ACTIONS), []);
const { active, startCapture, cancel } = useKeyCapture((actionId, value) => {
updateKeyBinding(actionId, value);
@@ -71,6 +163,38 @@ export default function KeymapSettings() {
[keymap],
);
const updateKeyboardSpeed = useCallback(
(key, nextValue) => {
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
const clamped = clampSpeed(nextValue, defaults[key]);
saveInputSettings((prev) => ({
...(prev ?? {}),
keyboard: {
...(prev?.keyboard ?? defaults),
[key]: clamped,
},
}));
},
[saveInputSettings],
);
const updateTiltInterval = useCallback(
(nextValue) => {
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
const clamped = clampTiltSpeed(nextValue, defaults.tiltSpeed);
const mappedInterval = mapTiltSpeedToInterval(clamped);
saveInputSettings((prev) => ({
...(prev ?? {}),
keyboard: {
...(prev?.keyboard ?? defaults),
tiltSpeed: clamped,
tiltIntervalMs: mappedInterval,
},
}));
},
[saveInputSettings],
);
return (
<section className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between">
@@ -82,6 +206,41 @@ export default function KeymapSettings() {
Reset defaults
</button>
</div>
<div className="space-y-0.5 surface">
<p className="text-[0.7rem] text-slate-500">Keyboard speeds</p>
<div className="space-y-0.5">
<SpeedField
label="Base speed"
description="Normal driving speed"
value={keyboardSettings.baseSpeed}
onChange={(value) => updateKeyboardSpeed('baseSpeed', value)}
/>
<SpeedField
label="Turbo speed"
description="Used when holding the boost modifier"
value={keyboardSettings.turboSpeed}
onChange={(value) => updateKeyboardSpeed('turboSpeed', value)}
/>
<SpeedField
label="Precision speed"
description="Used when holding the precision modifier"
value={keyboardSettings.precisionSpeed}
onChange={(value) => updateKeyboardSpeed('precisionSpeed', value)}
/>
</div>
</div>
<div className="space-y-0.5 surface">
<p className="text-[0.7rem] text-slate-500">Camera tilt</p>
<SpeedField
label="Tilt speed"
description="Higher = faster when holding tilt keys"
value={tiltSpeed}
onChange={updateTiltInterval}
min={1}
max={100}
step={1}
/>
</div>
<div className="space-y-0.5">
{Object.entries(grouped).map(([group, actions]) => (
<div key={group} className="space-y-0.5 surface">
+21
View File
@@ -0,0 +1,21 @@
import { useSession } from "../context/SessionContext";
import { FaCoffee } from "react-icons/fa";
export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
const { session } = useSession();
const kofiLink = session?.kofi?.link || null;
if (!kofiLink) return null;
return (
<a
href={kofiLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center"
>
<FaCoffee className="mr-1" />
{text}
</a>
);
}
+51 -33
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useControlSystem } from '../controls/index.js';
import { clampUnit } from '../controls/controlMath.js';
import DriveModeToggle from './controls/DriveModeToggle.jsx';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
const SOURCE = 'mobile-joystick';
const JOYSTICK_RADIUS = 80;
@@ -133,11 +133,15 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
function MobileJoystickPanel({ layout }) {
const {
state: { roverId, camera },
actions: { setDriveVector, registerInputState, stopAllMotion, setServoAngle },
pipeline,
actions: { setDriveVector, registerInputState, setServoAngle, toggleNightVision },
} = useControlSystem();
const driveDockState = useDriveDockState(roverId);
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
const disabled = !roverId;
const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
const cameraValue =
@@ -194,37 +198,51 @@ function MobileJoystickPanel({ layout }) {
setServoAngle(next);
};
return (
<div className="flex flex-col gap-0.5 text-slate-100">
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
<DriveModeToggle size="compact" />
{cameraEnabled && (
<div className="bg-zinc-950 p-0.5 text-xs">
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
<span>Camera Tilt</span>
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
</div>
<input
type="range"
min={cameraMin}
max={cameraMax}
step={0.5}
value={cameraValue}
onChange={handleCameraSlider}
className="mt-0.5 w-full accent-cyan-400"
return (
<div className={containerClass}>
<DriveDockAction layout="mobile" expand={dockedNotDriving} driveDockState={driveDockState} />
{!dockedNotDriving ? (
<>
{nightVisionAvailable && (
<button
type="button"
onClick={() => toggleNightVision()}
disabled={disabled}
className="bg-amber-600 px-0.5 py-1 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-40"
>
Toggle Night Vision
</button>
)}
{cameraEnabled && (
<div className="bg-zinc-950 p-0.5 text-xs">
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
<span>Camera Tilt</span>
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
</div>
<input
type="range"
min={cameraMin}
max={cameraMax}
step={0.5}
value={cameraValue}
onChange={handleCameraSlider}
className="mt-0.5 w-full accent-cyan-400"
/>
</div>
)}
<FloatingJoystick
disabled={disabled}
layout={layout}
radius={joystickRadius}
onMove={handleMove}
onStop={handleStop}
/>
</div>
)}
<FloatingJoystick
disabled={disabled}
layout={layout}
radius={joystickRadius}
onMove={handleMove}
onStop={handleStop}
/>
{/* <button type="button" onClick={stopAllMotion} disabled={disabled} className="button-danger w-full disabled:opacity-40">
Panic Stop
</button> */}
</>
) : null}
{/* Panic stop button can be re-enabled here if needed */}
</div>
);
@@ -266,7 +284,7 @@ function AuxMotorPanel({ orientation }) {
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
return (
<div className="flex flex-col gap-0.5 text-slate-100">
<div className="flex h-full flex-col gap-0.5 text-slate-100">
<p className="text-xs text-slate-400">Aux controls</p>
<div className={`grid ${gridCols} gap-0.5`}>
{AUX_BUTTONS.map((button) => (
@@ -311,7 +329,7 @@ export function MobileLandscapeControlColumn() {
export default function MobilePortraitControls() {
return (
<section className="panel">
<div className="grid grid-cols-2 gap-0.5">
<div className="grid grid-cols-2 gap-0.5 items-stretch">
<AuxMotorPanel orientation="portrait" />
<MobileJoystickPanel layout="portrait" />
</div>
+1 -1
View File
@@ -31,7 +31,7 @@ export default function NicknameForm({ compact = false }) {
}
return (
<form className="flex gap-0.5" onSubmit={handleSave}>
<form className="flex w-full gap-0.5" onSubmit={handleSave}>
<input
className="field-input flex-1"
value={nicknameInput}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
function normalizeSources(list = []) {
return list
.map((entry) => {
if (!entry?.type || !entry?.id) return null;
return {
type: entry.type,
id: String(entry.id),
label: entry.label || String(entry.id),
key: `${entry.type}:${entry.id}`,
};
})
.filter(Boolean);
}
export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
const { session, triggerReplay } = useSession();
const sources = normalizeSources(session?.replaySources || []);
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const replayState = session?.replay || null;
const [remainingMs, setRemainingMs] = useState(0);
const defaults = useMemo(() => {
const roverId = session?.assignment?.roverId;
if (roverId) {
return [`rover:${roverId}`];
}
return [];
}, [session?.assignment?.roverId]);
useEffect(() => {
const saved = settings?.[panelId];
if (Array.isArray(saved) && saved.length) {
setSelected(saved);
return;
}
setSelected(defaults);
}, [settings?.[panelId], defaults, panelId]);
useEffect(() => {
const allowed = new Set(sources.map((source) => source.key));
setSelected((prev) => prev.filter((key) => allowed.has(key)));
}, [sources]);
const grouped = useMemo(() => {
return {
rovers: sources.filter((source) => source.type === 'rover'),
rooms: sources.filter((source) => source.type === 'room'),
};
}, [sources]);
useEffect(() => {
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) {
setRemainingMs(0);
return undefined;
}
const update = () => {
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
setRemainingMs(Math.max(0, next));
};
update();
const interval = setInterval(update, 250);
return () => clearInterval(interval);
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
const replayDisabled = busy || session?.mode === 'lockdown' || remainingMs > 0 || !selected.length;
const toggleKey = (key) => {
setSelected((prev) => {
const next = prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
saveSettings((current) => ({ ...(current || {}), [panelId]: next }));
return next;
});
setSuccess(null);
};
const handleReplay = async () => {
if (replayDisabled) return;
setBusy(true);
setError(null);
setSuccess(null);
try {
const payload = selected.map((key) => {
const [type, id] = key.split(':');
return { type, id };
});
await triggerReplay(payload);
setSuccess('Replay sent. Check the Discord replay channel.');
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<section className="panel-section p-0.75 text-sm">
<header className="panel-muted flex items-center justify-between text-xs">
<span>Replay Sources</span>
<span>{sources.length}</span>
</header>
<div className="grid gap-0.5 md:grid-cols-2">
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
</div>
<div className="space-y-0.25">
<button
type="button"
className="button-dark w-full text-xs disabled:opacity-40"
onClick={handleReplay}
disabled={replayDisabled}
>
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
</button>
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
{success ? <div className="text-xs text-emerald-300">{success}</div> : null}
</div>
</section>
);
}
function GroupList({ title, items, selected, onToggle }) {
if (!items.length) return null;
return (
<div className="space-y-0.25">
<div className="panel-muted text-xs">{title}</div>
<div className="space-y-0.25">
{items.map((item) => (
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
<input
type="checkbox"
checked={selected.includes(item.key)}
onChange={() => onToggle(item.key)}
className="accent-emerald-400"
/>
<span className="truncate">{item.label}</span>
</label>
))}
</div>
</div>
);
}
+8 -16
View File
@@ -1,43 +1,35 @@
import TelemetryPanel from './TelemetryPanel.jsx';
import DrivePanel from './DrivePanel.jsx';
import CameraServoPanel from './CameraServoPanel.jsx';
import ControlSummary from './ControlSummary.jsx';
import RoomCameraPanel from './RoomCameraPanel.jsx';
import HomeAssistantControls from './HomeAssistantControls.jsx';
import SettingsPanel from './SettingsPanel.jsx';
import HelpPanel from './HelpPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
export default function RightPaneTabs({ layout }) {
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
return (
<section className="panel text-base">
<Tabs defaultTab="telemetry">
<TabList>
<Tab id="telemetry">Rover Controls</Tab>
<Tab id="room">Room Controls</Tab>
<Tab id="settings">Settings</Tab>
<Tab id="telemetry">Controls</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
<TabPanels>
<TabPanel id="telemetry">
<div className="space-y-0.5">
<DrivePanel />
<ControlSummary />
<HomeAssistantControls />
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
<TelemetryPanel />
<CameraServoPanel />
</div>
</TabPanel>
<TabPanel id="room">
<div className="space-y-0.5">
<RoomCameraPanel defaultOrientation="vertical" panelId="rightpane-room" />
<HomeAssistantControls />
</div>
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
</TabPanel>
<TabPanel id="settings">
<SettingsPanel />
</TabPanel>
<TabPanel id="help">
<HelpPanel layout={layout} />
</TabPanel>
</TabPanels>
</Tabs>
</section>

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