mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8bb8a0cf | ||
|
|
87936510b2 | ||
|
|
66b8c839b7 | ||
|
|
7041a3c6df | ||
|
|
2975d394b6 | ||
|
|
befcf7ad5a | ||
|
|
27ae3bcd1e | ||
|
|
90fd0262c7 | ||
|
|
64a9dd3583 | ||
|
|
972942e497 | ||
|
|
e34d838d41 | ||
|
|
705b38ea7b | ||
|
|
f129449d5a | ||
|
|
d4ce6e9092 | ||
|
|
2252527d89 | ||
|
|
d184590bf2 | ||
|
|
5b9167ab2a | ||
|
|
655fed4326 | ||
|
|
6c01087738 | ||
|
|
d272a33dc2 | ||
|
|
8212a14ea8 | ||
|
|
9622129c73 | ||
|
|
37927d82af | ||
|
|
23fe03ca54 | ||
|
|
899e647a9d | ||
|
|
cab4dc716d | ||
|
|
5caf26bb2d | ||
|
|
e666b4c195 | ||
|
|
c2ca951fe8 | ||
|
|
399c077c7a | ||
|
|
b8a4a18455 | ||
|
|
3aedaa26c8 | ||
|
|
a6e7e683ba | ||
|
|
27c46b44b2 | ||
|
|
5fdcb233f8 | ||
|
|
b7a79b03c9 | ||
|
|
fb00467106 | ||
|
|
32f9a2befc | ||
|
|
e10e2f9a0b | ||
|
|
8b0928f428 | ||
|
|
f3e13f6ab7 | ||
|
|
4ab6bad1b8 | ||
|
|
b4754b1c54 | ||
|
|
9aad7ff775 | ||
|
|
c3a9ea7281 | ||
|
|
ba652005f5 | ||
|
|
a49a090026 | ||
|
|
c265121335 | ||
|
|
56b89b5dff | ||
|
|
00f97c8005 | ||
|
|
d8ce85be60 | ||
|
|
3b67e81451 | ||
|
|
870fdc63be | ||
|
|
5ae0b0ca79 | ||
|
|
778543dd5c | ||
|
|
29154c56e6 | ||
|
|
50d3977814 | ||
|
|
acebcd7307 | ||
|
|
55782dabf0 | ||
|
|
79c118bc88 | ||
|
|
173d6fffa7 | ||
|
|
cf072ef0e0 | ||
|
|
30ebd05a97 | ||
|
|
d3f2f15370 | ||
|
|
008cf96c7e | ||
|
|
981262a0ae | ||
|
|
b884f23b13 | ||
|
|
dc9f93ca60 | ||
|
|
4005ced6f9 | ||
|
|
f7dd4f7b89 | ||
|
|
4b7c26a7b4 | ||
|
|
7335cefffb | ||
|
|
aba01161c9 | ||
|
|
b4edf72c9a | ||
|
|
066f976cf4 | ||
|
|
2f726035c9 | ||
|
|
5b8fb30e71 | ||
|
|
eb4e1621c0 | ||
|
|
6d55073bfb | ||
|
|
242fe461d9 | ||
|
|
fcc71426ee | ||
|
|
4e30eb1d52 | ||
|
|
e0cadb493f | ||
|
|
bbb4014f61 | ||
|
|
3c1c75a2ec | ||
|
|
c4dda507e2 | ||
|
|
56ed528412 | ||
|
|
258f244194 | ||
|
|
ec0e1564d7 | ||
|
|
4f2907a105 | ||
|
|
5829cd370c | ||
|
|
5ef6b640c6 | ||
|
|
ff638d84d2 | ||
|
|
061d4d4689 | ||
|
|
3cc12b76b9 | ||
|
|
1241c867a5 | ||
|
|
8b3806bcd5 | ||
|
|
eeb4e65df7 | ||
|
|
b714bd0fed | ||
|
|
a989f2029a | ||
|
|
ca1f7ef54a | ||
|
|
87db66ac1f | ||
|
|
8da2196ee0 | ||
|
|
49d822d468 | ||
|
|
fd765ec1da | ||
|
|
086d3cdee2 | ||
|
|
83f6f7eac1 | ||
|
|
3df303bc09 | ||
|
|
94a779581b | ||
|
|
c87767d3ee | ||
|
|
6ca30adcaf | ||
|
|
588305184e | ||
|
|
293b73dc20 | ||
|
|
d4440a4f05 | ||
|
|
3349120c0d | ||
|
|
bd862bf3f2 | ||
|
|
1a6ca5ce73 | ||
|
|
9ea29f8e36 | ||
|
|
b9d7a62b0e | ||
|
|
6301058f87 | ||
|
|
8e1de78cc7 | ||
|
|
0b564c7ee7 | ||
|
|
195ecb7d61 | ||
|
|
108de883a0 | ||
|
|
01df853592 | ||
|
|
020ddfea07 | ||
|
|
b4a5633bd4 | ||
|
|
f579516d64 | ||
|
|
6f887b00f1 | ||
|
|
0ce6c05e05 | ||
|
|
fe9f1c13e2 | ||
|
|
e3874b6237 | ||
|
|
e0a64e828e | ||
|
|
a8fb23287c | ||
|
|
ed9bb3216c | ||
|
|
1b013ed91b | ||
|
|
8e01dcaabd | ||
|
|
0836afc0c0 | ||
|
|
8296278e1c | ||
|
|
6028e275da | ||
|
|
314b88b146 | ||
|
|
de4295caf1 | ||
|
|
c5e66b3ec5 | ||
|
|
aba5efcf40 | ||
|
|
e69fd18256 | ||
|
|
0be105e9dd | ||
|
|
a1fef27a59 | ||
|
|
77ec19a4d6 | ||
|
|
d4d6f0674d | ||
|
|
e334246cef | ||
|
|
81a8c35873 | ||
|
|
9c3dbfebcb | ||
|
|
cd9e82938b | ||
|
|
0fb6ea49b3 | ||
|
|
a3dae83bf7 | ||
|
|
1bd486a6c0 | ||
|
|
b965f7b561 | ||
|
|
45aef7339e | ||
|
|
fb7ecec17e | ||
|
|
7b29d741b0 | ||
|
|
67f4f014c4 | ||
|
|
c42d314cc9 | ||
|
|
102192c631 | ||
|
|
cfe9055cb7 | ||
|
|
501bc234ee | ||
|
|
c07e5971f5 | ||
|
|
bbdecc324a | ||
|
|
8b59d021d3 | ||
|
|
3221dbe877 | ||
|
|
57d4b5a415 | ||
|
|
6580de08d4 | ||
|
|
161929e280 | ||
|
|
e9fd5f55a8 | ||
|
|
ae1c92a0ce | ||
|
|
127eb446ed | ||
|
|
66824240d8 | ||
|
|
115ecad9a6 | ||
|
|
d857668109 | ||
|
|
b72c58452e | ||
|
|
2b1122dce9 | ||
|
|
9f0bb8bd24 | ||
|
|
edeac5e6ff | ||
|
|
e0312c3fbb | ||
|
|
e94a52d7fd | ||
|
|
ace39d5a70 | ||
|
|
9bc5d2d06d | ||
|
|
57575f7199 | ||
|
|
2ad71a51c7 | ||
|
|
9dd17929df | ||
|
|
543c2b452e | ||
|
|
a65e80c82a | ||
|
|
185a826387 |
@@ -12,3 +12,6 @@ robots.json
|
|||||||
roverd-dummy
|
roverd-dummy
|
||||||
server/config.yaml
|
server/config.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
server/data/discord-guilds.json
|
||||||
|
server/data/community-goal.json
|
||||||
|
server/data
|
||||||
|
|||||||
Vendored
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
#configuration for roverd
|
#configuration for roverd
|
||||||
name: dummy1
|
name: dummy1
|
||||||
serverUrl: ws://192.168.0.84:8080/rover
|
serverUrl: ws://127.0.0.1:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyS0
|
device: /dev/ttyS0
|
||||||
baud: 115200
|
baud: 115200
|
||||||
@@ -18,3 +18,5 @@ media:
|
|||||||
service: mediamtx.service
|
service: mediamtx.service
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
|
nightVision:
|
||||||
|
enabled: false
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -32,32 +32,35 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
run_pipeline() {
|
run_pipeline() {
|
||||||
"${FFMPEG_BIN_PATH}" \
|
arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw \
|
||||||
-hide_banner \
|
| "${FFMPEG_BIN_PATH}" \
|
||||||
-loglevel warning \
|
-hide_banner \
|
||||||
-fflags nobuffer \
|
-loglevel warning \
|
||||||
-rtbufsize 0 \
|
-fflags nobuffer \
|
||||||
-thread_queue_size 4096 \
|
-rtbufsize 0 \
|
||||||
-f s32le \
|
-thread_queue_size 4096 \
|
||||||
-ar "${AUDIO_RATE}" \
|
-f s32le \
|
||||||
-ac "${AUDIO_CHANNELS}" \
|
-ar "${AUDIO_RATE}" \
|
||||||
-i <(arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw) \
|
-ac "${AUDIO_CHANNELS}" \
|
||||||
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=30dB" \
|
-i pipe:0 \
|
||||||
-c:a libopus \
|
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=25dB" \
|
||||||
-b:a 24000 \
|
-c:a libopus \
|
||||||
-ar:a 16000 \
|
-b:a 24000 \
|
||||||
-ac:a 1 \
|
-ar:a 16000 \
|
||||||
-application lowdelay \
|
-ac:a 1 \
|
||||||
-frame_duration 20 \
|
-application lowdelay \
|
||||||
-compression_level 0 \
|
-frame_duration 20 \
|
||||||
-f mpegts \
|
-compression_level 0 \
|
||||||
"${AUDIO_PUBLISH_URL}"
|
-f mpegts \
|
||||||
|
"${AUDIO_PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if run_pipeline; then
|
if run_pipeline; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Audio-only publisher exited, restarting in 2s..." >&2
|
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -52,10 +52,13 @@ run_pipeline() {
|
|||||||
--bitrate "${VIDEO_BITRATE}" \
|
--bitrate "${VIDEO_BITRATE}" \
|
||||||
--codec h264 \
|
--codec h264 \
|
||||||
--profile baseline \
|
--profile baseline \
|
||||||
--denoise cdn_off \
|
--denoise auto \
|
||||||
--nopreview \
|
--nopreview \
|
||||||
--awb custom \
|
--metering centre \
|
||||||
--awbgains 1.5,1.8 \
|
--ev 0.1 \
|
||||||
|
--awb auto \
|
||||||
|
--saturation 0.6 \
|
||||||
|
--brightness 0 \
|
||||||
--output - \
|
--output - \
|
||||||
| "${FFMPEG_BIN_PATH}" \
|
| "${FFMPEG_BIN_PATH}" \
|
||||||
-hide_banner \
|
-hide_banner \
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, er
|
|||||||
rpio.Close()
|
rpio.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle)
|
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg, %d..%d us, invert=%v)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle, cfg.MinPulseUs, cfg.MaxPulseUs, cfg.Invert)
|
||||||
return servo, nil
|
return servo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +118,9 @@ func (s *CameraServo) angleToPulse(angle float64) int {
|
|||||||
}
|
}
|
||||||
norm := (angle - s.cfg.MinAngle) / totalRange
|
norm := (angle - s.cfg.MinAngle) / totalRange
|
||||||
norm = math.Max(0, math.Min(1, norm))
|
norm = math.Max(0, math.Min(1, norm))
|
||||||
|
if s.cfg.Invert {
|
||||||
|
norm = 1 - norm
|
||||||
|
}
|
||||||
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
||||||
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
|
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
|
||||||
}
|
}
|
||||||
@@ -129,15 +132,8 @@ func (s *CameraServo) pulseToAngle(pulse int) float64 {
|
|||||||
}
|
}
|
||||||
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
|
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
|
||||||
norm = math.Max(0, math.Min(1, norm))
|
norm = math.Max(0, math.Min(1, norm))
|
||||||
|
if s.cfg.Invert {
|
||||||
|
norm = 1 - norm
|
||||||
|
}
|
||||||
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
|
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clampInt(value, min, max int) int {
|
|
||||||
if value < min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
if value > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -69,10 +69,19 @@ func main() {
|
|||||||
defer cameraServo.Close()
|
defer cameraServo.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nightVision *roverd.NightVisionLight
|
||||||
|
if cfg.NightVision.Enabled {
|
||||||
|
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("init night vision: %v", err)
|
||||||
|
}
|
||||||
|
defer nightVision.Close()
|
||||||
|
}
|
||||||
|
|
||||||
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
||||||
go autoCharge.Run(ctx, sensorSamples)
|
go autoCharge.Run(ctx, sensorSamples)
|
||||||
|
|
||||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, logger)
|
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
|
||||||
|
|
||||||
retryDelay := time.Second
|
retryDelay := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type helloMessage struct {
|
|||||||
Media MediaConfig `json:"media"`
|
Media MediaConfig `json:"media"`
|
||||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||||
Audio AudioConfig `json:"audio"`
|
Audio AudioConfig `json:"audio"`
|
||||||
|
NightVision NightVisionConfig `json:"nightVision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sensorMessage struct {
|
type sensorMessage struct {
|
||||||
@@ -26,6 +27,8 @@ type inboundMessage struct {
|
|||||||
Media *mediaCommand `json:"media,omitempty"`
|
Media *mediaCommand `json:"media,omitempty"`
|
||||||
Servo *servoPayload `json:"servo,omitempty"`
|
Servo *servoPayload `json:"servo,omitempty"`
|
||||||
TTS *ttsPayload `json:"tts,omitempty"`
|
TTS *ttsPayload `json:"tts,omitempty"`
|
||||||
|
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||||
|
Song *songPayload `json:"song,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
type driveDirectPayload struct {
|
||||||
@@ -61,6 +64,21 @@ type ttsPayload struct {
|
|||||||
Speak bool `json:"speak,omitempty"`
|
Speak bool `json:"speak,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nightVisionPayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type songPayload struct {
|
||||||
|
Slot *int `json:"slot,omitempty"`
|
||||||
|
Notes []songNote `json:"notes"`
|
||||||
|
Loop bool `json:"loop,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type songNote struct {
|
||||||
|
Note int `json:"note"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
type ackMessage struct {
|
type ackMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
+39
-1
@@ -93,6 +93,14 @@ type CameraServoConfig struct {
|
|||||||
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
|
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
|
||||||
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
|
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
|
||||||
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
|
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
|
||||||
|
Invert bool `yaml:"invert" json:"invert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NightVisionConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
|
||||||
|
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
|
||||||
|
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -105,6 +113,7 @@ type Config struct {
|
|||||||
Media MediaConfig `yaml:"media"`
|
Media MediaConfig `yaml:"media"`
|
||||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||||
Audio AudioConfig `yaml:"audio"`
|
Audio AudioConfig `yaml:"audio"`
|
||||||
|
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
@@ -154,6 +163,12 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
DefaultVoice: "rms",
|
DefaultVoice: "rms",
|
||||||
DefaultPitch: 50,
|
DefaultPitch: 50,
|
||||||
},
|
},
|
||||||
|
NightVision: NightVisionConfig{
|
||||||
|
Enabled: true,
|
||||||
|
GPIOPin: 22,
|
||||||
|
GPIOChip: "gpiochip0",
|
||||||
|
InitialOn: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -214,6 +229,9 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
||||||
|
return nil, fmt.Errorf("nightVision: %w", err)
|
||||||
|
}
|
||||||
validateAudioConfig(&cfg.Audio)
|
validateAudioConfig(&cfg.Audio)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
@@ -231,9 +249,16 @@ func validateServoConfig(cfg *CameraServoConfig) error {
|
|||||||
if cfg.CycleLen <= 0 {
|
if cfg.CycleLen <= 0 {
|
||||||
return errors.New("cycleLen must be > 0")
|
return errors.New("cycleLen must be > 0")
|
||||||
}
|
}
|
||||||
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 || cfg.MinPulseUs >= cfg.MaxPulseUs {
|
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 {
|
||||||
return errors.New("minPulseUs/maxPulseUs invalid")
|
return errors.New("minPulseUs/maxPulseUs invalid")
|
||||||
}
|
}
|
||||||
|
if cfg.MinPulseUs == cfg.MaxPulseUs {
|
||||||
|
return errors.New("minPulseUs/maxPulseUs cannot be equal")
|
||||||
|
}
|
||||||
|
if cfg.MinPulseUs > cfg.MaxPulseUs {
|
||||||
|
cfg.MinPulseUs, cfg.MaxPulseUs = cfg.MaxPulseUs, cfg.MinPulseUs
|
||||||
|
cfg.Invert = !cfg.Invert
|
||||||
|
}
|
||||||
if cfg.MinAngle >= cfg.MaxAngle {
|
if cfg.MinAngle >= cfg.MaxAngle {
|
||||||
return errors.New("minAngle must be less than maxAngle")
|
return errors.New("minAngle must be less than maxAngle")
|
||||||
}
|
}
|
||||||
@@ -278,6 +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) {
|
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||||
if streamName == "" {
|
if streamName == "" {
|
||||||
return "", errors.New("missing stream name for publishUrl")
|
return "", errors.New("missing stream name for publishUrl")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ cameraServo:
|
|||||||
cycleLen: 20000
|
cycleLen: 20000
|
||||||
minPulseUs: 900
|
minPulseUs: 900
|
||||||
maxPulseUs: 2100
|
maxPulseUs: 2100
|
||||||
|
invert: false
|
||||||
minAngle: -15
|
minAngle: -15
|
||||||
maxAngle: 30
|
maxAngle: 30
|
||||||
homeAngle: 0
|
homeAngle: 0
|
||||||
@@ -47,3 +48,8 @@ audio:
|
|||||||
defaultEngine: flite
|
defaultEngine: flite
|
||||||
defaultVoice: rms
|
defaultVoice: rms
|
||||||
defaultPitch: 50
|
defaultPitch: 50
|
||||||
|
nightVision:
|
||||||
|
enabled: true
|
||||||
|
gpioPin: 22
|
||||||
|
gpioChip: gpiochip0
|
||||||
|
initialOn: true
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
sensorHeader = 19
|
sensorHeader = 19
|
||||||
sensorReadTimeout = 150 * time.Millisecond
|
sensorReadTimeout = 150 * time.Millisecond
|
||||||
sensorThrottleMinimum = 250 * time.Millisecond
|
sensorThrottleMinimum = 50 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
type SensorStreamer struct {
|
type SensorStreamer struct {
|
||||||
|
|||||||
@@ -92,6 +92,33 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
|||||||
return s.write(raw)
|
return s.write(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) StartOI() error {
|
||||||
|
return s.write([]byte{128})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
func (s *SerialAdapter) SeekDock() error {
|
||||||
return s.write([]byte{143})
|
return s.write([]byte{143})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||||
|
if len(notes) == 0 {
|
||||||
|
return fmt.Errorf("song requires at least one note")
|
||||||
|
}
|
||||||
|
if len(notes) > 16 {
|
||||||
|
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
|
||||||
|
}
|
||||||
|
if slot < 0 || slot > 4 {
|
||||||
|
return fmt.Errorf("song slot must be 0-4")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte{140, byte(slot), byte(len(notes))}
|
||||||
|
for _, n := range notes {
|
||||||
|
note := clampInt(n.Note, 31, 127)
|
||||||
|
duration := clampInt(n.Duration, 1, 255)
|
||||||
|
payload = append(payload, byte(note), byte(duration))
|
||||||
|
}
|
||||||
|
if err := s.write(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.write([]byte{141, byte(slot)})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,17 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) StartOI() error {
|
||||||
|
s.log.Printf("[dummy] start OI")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
func (s *SerialAdapter) SeekDock() error {
|
||||||
s.log.Printf("[dummy] seek dock")
|
s.log.Printf("[dummy] seek dock")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||||
|
s.log.Printf("[dummy] play song slot=%d notes=%v", slot, notes)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+4
-4
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
|
func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) error {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return fmt.Errorf("tts payload required")
|
return fmt.Errorf("tts payload required")
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
|
|||||||
}
|
}
|
||||||
pitch = clampInt(pitch, 0, 99)
|
pitch = clampInt(pitch, 0, 99)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
@@ -55,14 +55,14 @@ func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
|
|||||||
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
||||||
}
|
}
|
||||||
args = append(args, text)
|
args = append(args, text)
|
||||||
cmd = exec.CommandContext(ctx, "espeak", args...)
|
cmd = exec.CommandContext(runCtx, "espeak", args...)
|
||||||
case "flite", "f":
|
case "flite", "f":
|
||||||
args := []string{}
|
args := []string{}
|
||||||
if voice != "" {
|
if voice != "" {
|
||||||
args = append(args, "-voice", voice)
|
args = append(args, "-voice", voice)
|
||||||
}
|
}
|
||||||
args = append(args, "-t", text)
|
args = append(args, "-t", text)
|
||||||
cmd = exec.CommandContext(ctx, "flite", args...)
|
cmd = exec.CommandContext(runCtx, "flite", args...)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported tts engine: %s", engine)
|
return fmt.Errorf("unsupported tts engine: %s", engine)
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-9
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
@@ -15,13 +16,25 @@ type WSClient struct {
|
|||||||
cfg *Config
|
cfg *Config
|
||||||
adapter *SerialAdapter
|
adapter *SerialAdapter
|
||||||
sensorFrames <-chan []byte
|
sensorFrames <-chan []byte
|
||||||
events <-chan RoverEvent
|
events chan RoverEvent
|
||||||
media *MediaSupervisor
|
media *MediaSupervisor
|
||||||
servo *CameraServo
|
servo *CameraServo
|
||||||
|
nightVision *NightVisionLight
|
||||||
log *log.Logger
|
log *log.Logger
|
||||||
|
recoverMu sync.Mutex
|
||||||
|
recovering bool
|
||||||
|
ttsQueue chan *ttsPayload
|
||||||
|
connMu sync.Mutex
|
||||||
|
connected bool
|
||||||
|
disconnectT *time.Timer
|
||||||
|
seekIssued bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, servo *CameraServo, logger *log.Logger) *WSClient {
|
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||||
|
var ttsQueue chan *ttsPayload
|
||||||
|
if cfg.Audio.TTSEnabled {
|
||||||
|
ttsQueue = make(chan *ttsPayload, 2)
|
||||||
|
}
|
||||||
return &WSClient{
|
return &WSClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
@@ -29,16 +42,21 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
|||||||
events: events,
|
events: events,
|
||||||
media: media,
|
media: media,
|
||||||
servo: servo,
|
servo: servo,
|
||||||
|
nightVision: nightVision,
|
||||||
log: logger,
|
log: logger,
|
||||||
|
ttsQueue: ttsQueue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) Run(ctx context.Context) error {
|
func (c *WSClient) Run(ctx context.Context) error {
|
||||||
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
|
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.markDisconnected()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.markConnected()
|
||||||
defer conn.Close(websocket.StatusInternalError, "closed")
|
defer conn.Close(websocket.StatusInternalError, "closed")
|
||||||
|
defer c.markDisconnected()
|
||||||
|
|
||||||
if err := c.sendHello(ctx, conn); err != nil {
|
if err := c.sendHello(ctx, conn); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -48,6 +66,7 @@ func (c *WSClient) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
c.startTTSWorker(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
errCh <- c.readLoop(ctx, conn)
|
errCh <- c.readLoop(ctx, conn)
|
||||||
}()
|
}()
|
||||||
@@ -72,6 +91,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
|||||||
Media: c.cfg.Media,
|
Media: c.cfg.Media,
|
||||||
CameraServo: c.cfg.CameraServo,
|
CameraServo: c.cfg.CameraServo,
|
||||||
Audio: c.cfg.Audio,
|
Audio: c.cfg.Audio,
|
||||||
|
NightVision: c.cfg.NightVision,
|
||||||
}
|
}
|
||||||
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
||||||
return writeJSON(ctx, conn, msg)
|
return writeJSON(ctx, conn, msg)
|
||||||
@@ -123,12 +143,9 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
return c.adapter.MotorPWM(main, side, vac)
|
return c.adapter.MotorPWM(main, side, vac)
|
||||||
case msg.SensorStream != nil:
|
case msg.SensorStream != nil:
|
||||||
if msg.SensorStream.Enable {
|
if msg.SensorStream.Enable {
|
||||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
return c.adapter.StartSensorStream(defaultStreamPackets)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.adapter.PauseSensorStream(false)
|
|
||||||
}
|
}
|
||||||
return c.adapter.PauseSensorStream(true)
|
return nil
|
||||||
case msg.Raw != "" && len(msg.Raw) > 0:
|
case msg.Raw != "" && len(msg.Raw) > 0:
|
||||||
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,12 +169,57 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
}
|
}
|
||||||
return c.handleServoCommand(msg.Servo)
|
return c.handleServoCommand(msg.Servo)
|
||||||
case msg.TTS != nil:
|
case msg.TTS != nil:
|
||||||
return c.handleTTSPayload(msg.TTS)
|
return c.enqueueTTS(msg.TTS)
|
||||||
|
case msg.NightVision != nil:
|
||||||
|
if c.nightVision == nil {
|
||||||
|
return fmt.Errorf("night vision disabled")
|
||||||
|
}
|
||||||
|
return c.nightVision.HandleAction(msg.NightVision.Action)
|
||||||
|
case msg.Song != nil:
|
||||||
|
slot := 0
|
||||||
|
if msg.Song.Slot != nil {
|
||||||
|
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||||
|
}
|
||||||
|
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
|
||||||
|
if c.ttsQueue == nil {
|
||||||
|
return fmt.Errorf("tts disabled")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.ttsQueue <- payload:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("tts busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) startTTSWorker(ctx context.Context) {
|
||||||
|
if c.ttsQueue == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case payload := <-c.ttsQueue:
|
||||||
|
if payload == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.handleTTSPayload(ctx, payload); err != nil {
|
||||||
|
c.log.Printf("tts failed: %v", err)
|
||||||
|
c.emitEvent("tts.error", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
||||||
switch {
|
switch {
|
||||||
case payload.Angle != nil:
|
case payload.Angle != nil:
|
||||||
@@ -172,11 +234,50 @@ func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
|
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
|
||||||
|
const (
|
||||||
|
sensorSilenceTimeout = 5 * time.Second
|
||||||
|
sensorRecoveryCooldown = 3 * time.Second
|
||||||
|
sensorCommandPause = 50 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
timer := time.NewTimer(sensorSilenceTimeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
resetTimer := func() {
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(sensorSilenceTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRecovery := time.Time{}
|
||||||
|
lastFrame := time.Now()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
now := time.Now()
|
||||||
|
if !lastRecovery.IsZero() && now.Sub(lastRecovery) < sensorRecoveryCooldown {
|
||||||
|
resetTimer()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
idleFor := now.Sub(lastFrame)
|
||||||
|
if idleFor < 0 {
|
||||||
|
idleFor = sensorSilenceTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
c.recoverSensorStream(idleFor, sensorCommandPause)
|
||||||
|
lastRecovery = now
|
||||||
|
resetTimer()
|
||||||
case frame := <-c.sensorFrames:
|
case frame := <-c.sensorFrames:
|
||||||
|
lastFrame = time.Now()
|
||||||
|
resetTimer()
|
||||||
msg := sensorMessage{
|
msg := sensorMessage{
|
||||||
Type: "sensor",
|
Type: "sensor",
|
||||||
Timestamp: time.Now().UnixMilli(),
|
Timestamp: time.Now().UnixMilli(),
|
||||||
@@ -210,6 +311,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) emitEvent(event string, data map[string]any) {
|
||||||
|
if c.events == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.events <- RoverEvent{
|
||||||
|
Type: "event",
|
||||||
|
Event: event,
|
||||||
|
Ts: time.Now().UnixMilli(),
|
||||||
|
Data: data,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||||
data, err := json.Marshal(v)
|
data, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,7 +348,86 @@ func (c *WSClient) ensureSensorStream() error {
|
|||||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.adapter.PauseSensorStream(false)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnectSeekDelay = time.Minute
|
||||||
|
|
||||||
|
func (c *WSClient) markConnected() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
c.connected = true
|
||||||
|
c.seekIssued = false
|
||||||
|
if c.disconnectT != nil {
|
||||||
|
c.disconnectT.Stop()
|
||||||
|
c.disconnectT = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) markDisconnected() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.connected {
|
||||||
|
c.connected = false
|
||||||
|
}
|
||||||
|
if c.disconnectT == nil {
|
||||||
|
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) handleDisconnectTimeout() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.connected || c.seekIssued {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.seekIssued = true
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
if err := c.adapter.SeekDock(); err != nil {
|
||||||
|
c.log.Printf("seek dock on disconnect failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.log.Printf("seek dock issued after websocket disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
|
||||||
|
c.recoverMu.Lock()
|
||||||
|
if c.recovering {
|
||||||
|
c.recoverMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.recovering = true
|
||||||
|
c.recoverMu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
c.recoverMu.Lock()
|
||||||
|
c.recovering = false
|
||||||
|
c.recoverMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.emitEvent("sensorWatchdog.restart", map[string]any{
|
||||||
|
"idleMs": idleFor.Milliseconds(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := c.adapter.StartOI(); err != nil {
|
||||||
|
c.log.Printf("watchdog start OI failed: %v", err)
|
||||||
|
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cmdPause > 0 {
|
||||||
|
time.Sleep(cmdPause)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||||
|
c.log.Printf("watchdog start stream failed: %v", err)
|
||||||
|
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.emitEvent("sensorWatchdog.ok", map[string]any{
|
||||||
|
"idleMs": idleFor.Milliseconds(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func isModeOpcode(op byte) bool {
|
func isModeOpcode(op byte) bool {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ User=roverd
|
|||||||
Group=roverd
|
Group=roverd
|
||||||
EnvironmentFile=/var/lib/roverd/video.env
|
EnvironmentFile=/var/lib/roverd/video.env
|
||||||
ExecStart=/usr/local/bin/audio-only-publisher
|
ExecStart=/usr/local/bin/audio-only-publisher
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=5
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -1,14 +1,11 @@
|
|||||||
## raspberry pi
|
## raspberry pi
|
||||||
- need to throttle sensor sending, maybe only send one in every 5 packets.
|
<!-- - need to throttle sensor sending, maybe only send one in every 5 packets. -->
|
||||||
|
|
||||||
## 3d models for camera:
|
## 3d models for camera:
|
||||||
- https://www.thingiverse.com/thing:2873677
|
- https://www.thingiverse.com/thing:2873677
|
||||||
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
|
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
|
||||||
- https://www.thingiverse.com/thing:4514531
|
- https://www.thingiverse.com/thing:4514531
|
||||||
|
|
||||||
## spectator page
|
|
||||||
- completely remake it from scratch
|
|
||||||
|
|
||||||
## todo
|
## todo
|
||||||
<!-- 1. battery manager -->
|
<!-- 1. battery manager -->
|
||||||
<!-- 2. pi-side sensor throttle (1/5th) -->
|
<!-- 2. pi-side sensor throttle (1/5th) -->
|
||||||
@@ -22,6 +19,9 @@
|
|||||||
<!-- 9. online user list -->
|
<!-- 9. online user list -->
|
||||||
<!-- 10. chat -->
|
<!-- 10. chat -->
|
||||||
<!-- 9. discord invite button -->
|
<!-- 9. discord invite button -->
|
||||||
10. redo both mobile layouts
|
<!-- 10. redo both mobile layouts -->
|
||||||
11. redo spectator view (last)
|
<!-- 11. redo spectator view (last) -->
|
||||||
21. finally.. set the favicon and title
|
<!-- 21. finally.. set the favicon and title -->
|
||||||
|
22. rover snapshots freezing and never coming back
|
||||||
|
23. rover snapshot -> video switching needs to be smoother, no black flash. connect and play before showing.
|
||||||
|
24. rover snapshots delayed (not just because of framerate)
|
||||||
@@ -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
|
||||||
|
-
|
||||||
@@ -2,41 +2,158 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Configurable via environment
|
# Configurable via environment
|
||||||
DEVICE="${DEVICE:-/dev/video0}"
|
export DEVICE="${DEVICE:-/dev/video0}"
|
||||||
RESOLUTION="${RESOLUTION:-640x480}"
|
export RESOLUTION="${RESOLUTION:-640x480}"
|
||||||
QUALITY="${QUALITY:-5}" # ffmpeg MJPEG quality (lower is better)
|
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
|
||||||
PORT="${PORT:-8088}"
|
export PORT="${PORT:-8088}"
|
||||||
WORKDIR="${WORKDIR:-/run/roomcam}"
|
export WORKDIR="${WORKDIR:-/run/roomcam}"
|
||||||
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
|
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
|
||||||
INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
|
export INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
|
||||||
|
export MJPEG_FPS="${MJPEG_FPS:-15}"
|
||||||
|
export MJPEG_QUALITY="${MJPEG_QUALITY:-8}"
|
||||||
|
|
||||||
mkdir -p "${WORKDIR}"
|
mkdir -p "${WORKDIR}"
|
||||||
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
||||||
rm -f "${SNAPSHOT_PATH}"
|
rm -f "${SNAPSHOT_PATH}"
|
||||||
|
# push
|
||||||
cleanup() {
|
cleanup() {
|
||||||
[[ -n "${FFMPEG_PID:-}" ]] && kill "${FFMPEG_PID}" 2>/dev/null || true
|
|
||||||
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
|
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
trap 'exit 0' SIGTERM INT
|
trap 'exit 0' SIGTERM INT
|
||||||
|
|
||||||
FFMPEG_INPUT_ARGS=(-f v4l2 -input_format "${INPUT_FORMAT}" -video_size "${RESOLUTION}" -i "${DEVICE}")
|
cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
|
||||||
FFMPEG_FILTERS=()
|
import os
|
||||||
if [[ "${INPUT_FORMAT}" == bayer_* ]]; then
|
import threading
|
||||||
# Convert raw Bayer to a JPEG-friendly pixel format.
|
import time
|
||||||
FFMPEG_FILTERS=(-pix_fmt yuv420p)
|
import subprocess
|
||||||
fi
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
/usr/bin/ffmpeg -y \
|
DEVICE = os.environ.get("DEVICE", "/dev/video0")
|
||||||
-loglevel warning -nostats \
|
RESOLUTION = os.environ.get("RESOLUTION", "640x480")
|
||||||
"${FFMPEG_INPUT_ARGS[@]}" \
|
INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
|
||||||
"${FFMPEG_FILTERS[@]}" \
|
MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
|
||||||
-q:v "${QUALITY}" \
|
MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
|
||||||
-f image2 -update 1 "${SNAPSHOT_PATH}" &
|
WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
|
||||||
FFMPEG_PID=$!
|
SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
|
||||||
|
|
||||||
/usr/bin/python3 -u -m http.server "${PORT}" --directory "${WORKDIR}" --bind 0.0.0.0 &
|
FFMPEG_INPUT_ARGS = [
|
||||||
|
"-f", "v4l2",
|
||||||
|
"-input_format", INPUT_FORMAT,
|
||||||
|
"-video_size", RESOLUTION,
|
||||||
|
"-i", DEVICE,
|
||||||
|
]
|
||||||
|
FFMPEG_FILTERS = []
|
||||||
|
if INPUT_FORMAT.startswith("bayer_"):
|
||||||
|
FFMPEG_FILTERS = ["-pix_fmt", "yuv420p"]
|
||||||
|
|
||||||
|
FRAME_LOCK = threading.Lock()
|
||||||
|
FRAME_EVENT = threading.Event()
|
||||||
|
LATEST_FRAME = b""
|
||||||
|
|
||||||
|
def spawn_mjpeg():
|
||||||
|
cmd = [
|
||||||
|
"/usr/bin/ffmpeg",
|
||||||
|
"-loglevel", "warning", "-nostats",
|
||||||
|
*FFMPEG_INPUT_ARGS,
|
||||||
|
*FFMPEG_FILTERS,
|
||||||
|
"-r", str(MJPEG_FPS),
|
||||||
|
"-q:v", str(MJPEG_QUALITY),
|
||||||
|
"-f", "mjpeg",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
def update_frame(frame_bytes):
|
||||||
|
global LATEST_FRAME
|
||||||
|
with FRAME_LOCK:
|
||||||
|
LATEST_FRAME = frame_bytes
|
||||||
|
FRAME_EVENT.set()
|
||||||
|
try:
|
||||||
|
with open(SNAPSHOT_PATH, "wb") as fh:
|
||||||
|
fh.write(frame_bytes)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def frame_reader():
|
||||||
|
while True:
|
||||||
|
proc = spawn_mjpeg()
|
||||||
|
buffer = b""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = proc.stdout.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer += chunk
|
||||||
|
while True:
|
||||||
|
start = buffer.find(b"\xff\xd8")
|
||||||
|
end = buffer.find(b"\xff\xd9", start + 2)
|
||||||
|
if start == -1 or end == -1:
|
||||||
|
break
|
||||||
|
frame = buffer[start : end + 2]
|
||||||
|
buffer = buffer[end + 2 :]
|
||||||
|
update_frame(frame)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/" or self.path == "/snapshot.jpg":
|
||||||
|
with FRAME_LOCK:
|
||||||
|
frame = LATEST_FRAME
|
||||||
|
if not frame:
|
||||||
|
self.send_error(404, "snapshot missing")
|
||||||
|
return
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "image/jpeg")
|
||||||
|
self.send_header("Content-Length", str(len(frame)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(frame)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.path == "/stream.mjpg":
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
FRAME_EVENT.wait(timeout=2)
|
||||||
|
FRAME_EVENT.clear()
|
||||||
|
with FRAME_LOCK:
|
||||||
|
frame = LATEST_FRAME
|
||||||
|
if not frame:
|
||||||
|
continue
|
||||||
|
header = (
|
||||||
|
b"--frame\r\n"
|
||||||
|
b"Content-Type: image/jpeg\r\n"
|
||||||
|
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
|
||||||
|
)
|
||||||
|
self.wfile.write(header)
|
||||||
|
self.wfile.write(frame)
|
||||||
|
self.wfile.write(b"\r\n")
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_error(404, "not found")
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def main():
|
||||||
|
threading.Thread(target=frame_reader, daemon=True).start()
|
||||||
|
addr = ("0.0.0.0", int(os.environ.get("PORT", "8088")))
|
||||||
|
ThreadingHTTPServer(addr, Handler).serve_forever()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
PY
|
||||||
|
|
||||||
|
/usr/bin/python3 -u "${WORKDIR}/mjpeg_server.py" &
|
||||||
HTTP_PID=$!
|
HTTP_PID=$!
|
||||||
|
|
||||||
wait -n "${FFMPEG_PID}" "${HTTP_PID}"
|
wait -n "${HTTP_PID}"
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ Environment=QUALITY=5
|
|||||||
Environment=PORT=8088
|
Environment=PORT=8088
|
||||||
Environment=WORKDIR=/run/roomcam
|
Environment=WORKDIR=/run/roomcam
|
||||||
Environment=INPUT_FORMAT=mjpeg
|
Environment=INPUT_FORMAT=mjpeg
|
||||||
WorkingDirectory=/home/daniel/gits/MultiRoombaRover/roomcam-service
|
ExecStart=/usr/bin/env bash /usr/local/bin/room-cam-snapshot.sh
|
||||||
ExecStart=/usr/bin/env bash /home/daniel/gits/MultiRoombaRover/roomcam-service/room-cam-snapshot.sh
|
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
User=root
|
User=root
|
||||||
|
|||||||
@@ -7,11 +7,21 @@ admins:
|
|||||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||||
discord_id: "0987654321"
|
discord_id: "0987654321"
|
||||||
lockdown: true
|
lockdown: true
|
||||||
|
timezone: "America/New_York"
|
||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
# Example: http://192.168.0.86:8889/video
|
# Example: http://192.168.0.86:8889/video
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||||
|
preview:
|
||||||
|
enabled: false
|
||||||
|
codec: "av1"
|
||||||
|
transport: "rtsp"
|
||||||
|
fps: 10
|
||||||
|
width: 640
|
||||||
|
roomBitrateKbps: 200
|
||||||
|
roverBitrateKbps: 350
|
||||||
|
gopSeconds: 2
|
||||||
|
|
||||||
homeAssistant:
|
homeAssistant:
|
||||||
url: "http://homeassistant.local:8123"
|
url: "http://homeassistant.local:8123"
|
||||||
@@ -27,10 +37,12 @@ roomCameras:
|
|||||||
name: "Lobby Camera"
|
name: "Lobby Camera"
|
||||||
description: "Wide shot of the staging area."
|
description: "Wide shot of the staging area."
|
||||||
url: "http://192.168.0.50/snapshot.jpg"
|
url: "http://192.168.0.50/snapshot.jpg"
|
||||||
|
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||||
- id: "workshop"
|
- id: "workshop"
|
||||||
name: "Workshop Bench"
|
name: "Workshop Bench"
|
||||||
description: "Shows the workbench and charging docks."
|
description: "Shows the workbench and charging docks."
|
||||||
url: "http://192.168.0.51/snapshot.jpg"
|
url: "http://192.168.0.51/snapshot.jpg"
|
||||||
|
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
token: "DISCORD_BOT_TOKEN"
|
token: "DISCORD_BOT_TOKEN"
|
||||||
@@ -38,7 +50,8 @@ discord:
|
|||||||
channels:
|
channels:
|
||||||
announcements: "123456789012345678"
|
announcements: "123456789012345678"
|
||||||
adminAlerts: "123456789012345678"
|
adminAlerts: "123456789012345678"
|
||||||
chatBridge: "123456789012345678"
|
# chat bridge is configured per guild via `rs bridge` commands
|
||||||
|
replay: "123456789012345678"
|
||||||
roles:
|
roles:
|
||||||
announcementPing: "123456789012345678"
|
announcementPing: "123456789012345678"
|
||||||
adminPing: "123456789012345678"
|
adminPing: "123456789012345678"
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ require('./src/services/roverConnectionService');
|
|||||||
require('./src/services/assignmentService');
|
require('./src/services/assignmentService');
|
||||||
require('./src/services/nicknameService');
|
require('./src/services/nicknameService');
|
||||||
require('./src/services/chatService');
|
require('./src/services/chatService');
|
||||||
|
require('./src/services/communityGoalService');
|
||||||
require('./src/services/videoSessions');
|
require('./src/services/videoSessions');
|
||||||
require('./src/services/videoAuthService');
|
require('./src/services/videoAuthService');
|
||||||
require('./src/services/videoSocketService');
|
require('./src/services/videoSocketService');
|
||||||
require('./src/services/roomCameraSocketService');
|
require('./src/services/roomCameraSocketService');
|
||||||
|
require('./src/services/roverSnapshotSocketService');
|
||||||
|
require('./src/services/embedHttpService');
|
||||||
require('./src/services/logStreamService');
|
require('./src/services/logStreamService');
|
||||||
require('./src/services/homeAssistantService');
|
require('./src/services/homeAssistantService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
|
require('./src/services/replaySocketService');
|
||||||
|
require('./src/services/replaySegmentManager');
|
||||||
|
require('./src/services/media/previewTranscoderService');
|
||||||
require('./src/services/discordBotService');
|
require('./src/services/discordBotService');
|
||||||
require('./src/services/httpServer');
|
require('./src/services/httpServer');
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
|||||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||||
|
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||||
|
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This installer must be run with sudo/root." >&2
|
echo "This installer must be run with sudo/root." >&2
|
||||||
@@ -74,6 +76,10 @@ install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
|||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||||
|
|
||||||
echo "[4/6] Writing systemd units..."
|
echo "[4/6] Writing systemd units..."
|
||||||
|
mkdir -p "$SNAPSHOT_DIR"
|
||||||
|
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
||||||
|
mkdir -p "$REPLAY_SEGMENT_DIR"
|
||||||
|
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
||||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
cat > "$MEDIAMTX_SERVICE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=mediaMTX WebRTC Server
|
Description=mediaMTX WebRTC Server
|
||||||
@@ -84,6 +90,7 @@ Wants=network-online.target
|
|||||||
User=$TARGET_USER
|
User=$TARGET_USER
|
||||||
Group=$TARGET_USER
|
Group=$TARGET_USER
|
||||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
||||||
|
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
@@ -104,6 +111,8 @@ Group=$TARGET_USER
|
|||||||
WorkingDirectory=$SERVER_DIR
|
WorkingDirectory=$SERVER_DIR
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||||
|
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||||
|
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ metricsAddress: 0.0.0.0:9998
|
|||||||
pprof: no
|
pprof: no
|
||||||
pprofAddress: 127.0.0.1:9999
|
pprofAddress: 127.0.0.1:9999
|
||||||
|
|
||||||
rtsp: no
|
rtsp: yes
|
||||||
rtmp: no
|
rtmp: no
|
||||||
hls: no
|
hls: no
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"home-assistant-js-websocket": "^3.1.2",
|
"home-assistant-js-websocket": "^3.1.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CnvHZcbU.js"></script>
|
<script type="module" crossorigin src="/assets/index-B7raLs13.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
const path = require('path');
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const morgan = require('morgan');
|
const morgan = require('morgan');
|
||||||
@@ -7,10 +6,7 @@ const config = require('./config');
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(morgan('dev'));
|
app.use(morgan('dev'));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(config.staticDir));
|
app.use(express.static(config.staticDir, { index: false }));
|
||||||
app.get('/spectate', (req, res) => {
|
|
||||||
res.sendFile(path.join(config.staticDir, 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const httpServer = http.createServer(app);
|
const httpServer = http.createServer(app);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ const { httpServer } = require('./http');
|
|||||||
|
|
||||||
const io = new SocketIOServer(httpServer, {
|
const io = new SocketIOServer(httpServer, {
|
||||||
cors: { origin: '*' },
|
cors: { origin: '*' },
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
pingInterval: 5000,
|
||||||
|
pingTimeout: 7000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow more service listeners without warnings.
|
// Allow more service listeners without warnings.
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|||||||
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||||
roverWSS.emit('connection', ws, req);
|
roverWSS.emit('connection', ws, req);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
info: '#2196f3',
|
|
||||||
success: '#4caf50',
|
|
||||||
warn: '#f0b651',
|
|
||||||
error: '#e53935',
|
|
||||||
};
|
|
||||||
|
|
||||||
function sendAlert({ color, title, message, ts = Date.now() }) {
|
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||||
const payload = {
|
const payload = {
|
||||||
color: color || COLORS.info,
|
color: color || '#2196f3',
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
ts,
|
ts,
|
||||||
@@ -19,6 +12,5 @@ function sendAlert({ color, title, message, ts = Date.now() }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
COLORS,
|
|
||||||
sendAlert,
|
sendAlert,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ roverManager.managerEvents.on('rover', ({ action }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
||||||
|
if (!socketId || !roverId) return;
|
||||||
|
const socket = socketRefs.get(socketId);
|
||||||
|
if (!socket) return;
|
||||||
|
assignments.set(socketId, roverId);
|
||||||
|
waiting.delete(socketId);
|
||||||
|
assignmentEvents.emit('update', socketId);
|
||||||
|
});
|
||||||
|
|
||||||
function assignSocket(socket) {
|
function assignSocket(socket) {
|
||||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||||
return;
|
return;
|
||||||
@@ -177,4 +186,15 @@ module.exports = {
|
|||||||
assignmentEvents,
|
assignmentEvents,
|
||||||
describeAssignment,
|
describeAssignment,
|
||||||
forceRelease,
|
forceRelease,
|
||||||
|
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||||
|
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||||
|
if (!socket || !roverId) return;
|
||||||
|
const previous = assignments.get(socket.id);
|
||||||
|
if (previous && previous !== roverId && releasePrevious) {
|
||||||
|
roverManager.releaseControl(previous, socket);
|
||||||
|
}
|
||||||
|
assignments.set(socket.id, roverId);
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.5;
|
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.01;
|
||||||
|
// const waitingHalfOrMore = true;
|
||||||
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
|
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
|
||||||
|
|
||||||
if (shouldUnlock) {
|
if (shouldUnlock) {
|
||||||
@@ -172,4 +173,16 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLockEvent({ roverId, locked, reason }) {
|
||||||
|
const state = getState(roverId);
|
||||||
|
if (!state) return;
|
||||||
|
if (!locked) {
|
||||||
|
state.batteryLocked = false;
|
||||||
|
state.waitingSince = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.batteryLocked = reason === 'battery';
|
||||||
|
}
|
||||||
|
|
||||||
managerEvents.on('sensor', handleSensorEvent);
|
managerEvents.on('sensor', handleSensorEvent);
|
||||||
|
managerEvents.on('lock', handleLockEvent);
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ const RATE_LIMIT_WINDOW_MS = 8000;
|
|||||||
const RATE_LIMIT_MAX = 5;
|
const RATE_LIMIT_MAX = 5;
|
||||||
const rateBuckets = new Map(); // socketId -> [timestamps]
|
const rateBuckets = new Map(); // socketId -> [timestamps]
|
||||||
|
|
||||||
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
|
const MAX_HISTORY = 100;
|
||||||
|
const history = [];
|
||||||
|
|
||||||
|
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick', 'faggot', 'fag', 'whore'];
|
||||||
const DUPLICATE_WINDOW_MS = 15000;
|
const DUPLICATE_WINDOW_MS = 15000;
|
||||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
||||||
|
|
||||||
@@ -71,12 +74,27 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
roverId,
|
roverId,
|
||||||
fromDiscord: Boolean(meta.fromDiscord),
|
fromDiscord: Boolean(meta.fromDiscord),
|
||||||
|
discordGuildId: meta.discordGuildId || null,
|
||||||
|
discordGuildName: meta.discordGuildName || null,
|
||||||
|
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||||
|
discordChannelId: meta.discordChannelId || null,
|
||||||
|
discordUserId: meta.discordUserId || null,
|
||||||
|
discordUserName: meta.discordUserName || null,
|
||||||
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||||
text,
|
text,
|
||||||
tts: meta.tts || null,
|
tts: meta.tts || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pushHistory(message) {
|
||||||
|
history.push(message);
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function broadcastMessage(message) {
|
function broadcastMessage(message) {
|
||||||
|
pushHistory(message);
|
||||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +127,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
|||||||
cb({ error: 'Slow down' });
|
cb({ error: 'Slow down' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (clean.length > 256) {
|
if (clean.length > 150) {
|
||||||
cb({ error: 'Message too long' });
|
cb({ error: 'Message too long' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,10 +139,10 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
|||||||
// cb({ error: 'Duplicate message' });
|
// cb({ error: 'Duplicate message' });
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
if (isKeymash(clean)) {
|
// if (isKeymash(clean)) {
|
||||||
cb({ error: 'Message looks like spam' });
|
// cb({ error: 'Message looks like spam' });
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
const roverId = resolveRoverId(socket?.id);
|
const roverId = resolveRoverId(socket?.id);
|
||||||
const ttsOptions = normalizeTtsOptions(tts);
|
const ttsOptions = normalizeTtsOptions(tts);
|
||||||
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
||||||
@@ -140,7 +158,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
const audio = record?.meta?.audio || {};
|
const audio = record?.meta?.audio || {};
|
||||||
const ttsEnabled = Boolean(audio.ttsEnabled);
|
const ttsEnabled = Boolean(audio.ttsEnabled);
|
||||||
if (!ttsEnabled) return;
|
if (!ttsEnabled) return;
|
||||||
if (!roverManager.canDrive(message.roverId, socket)) return;
|
// if (!roverManager.canDrive(message.roverId, socket)) return;
|
||||||
try {
|
try {
|
||||||
issueCommand(message.roverId, {
|
issueCommand(message.roverId, {
|
||||||
type: 'tts',
|
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 normalized = normalizeUserText(text);
|
||||||
const clean = normalized.trim();
|
const clean = normalized.trim();
|
||||||
if (!clean || clean.length > 256) {
|
if (!clean || clean.length > 256) {
|
||||||
@@ -175,6 +205,13 @@ function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', rover
|
|||||||
role,
|
role,
|
||||||
roverId,
|
roverId,
|
||||||
fromDiscord: true,
|
fromDiscord: true,
|
||||||
|
discordGuildId,
|
||||||
|
discordGuildName,
|
||||||
|
discordGuildIconUrl,
|
||||||
|
discordChannelId,
|
||||||
|
discordUserId,
|
||||||
|
discordUserName,
|
||||||
|
discordUserAvatarUrl,
|
||||||
});
|
});
|
||||||
logger.info('External chat message', { roverId, nickname });
|
logger.info('External chat message', { roverId, nickname });
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
@@ -182,6 +219,7 @@ function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', rover
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
|
socket.emit('chat:init', history);
|
||||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
|
const { isAdmin } = require('./roleService');
|
||||||
const logger = require('../globals/logger').child('commandService');
|
const logger = require('../globals/logger').child('commandService');
|
||||||
|
|
||||||
const pendingCommands = new Map(); // id -> { roverId }
|
const pendingCommands = new Map(); // id -> { roverId }
|
||||||
|
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||||
|
const driveCooldowns = new Map(); // roverId -> blockedUntil
|
||||||
|
|
||||||
function issueCommand(roverId, payload) {
|
function issueCommand(roverId, payload) {
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
@@ -31,9 +34,27 @@ function handleAck(msg) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRecentDriveActivity(windowMs, options = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const results = [];
|
||||||
|
for (const [roverId, info] of lastDriveActivity.entries()) {
|
||||||
|
if (!info || now - info.ts > windowMs) continue;
|
||||||
|
if (options.excludeAdmins && info.isAdmin) continue;
|
||||||
|
results.push({ roverId, ...info });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDriveCooldown(roverId, durationMs) {
|
||||||
|
if (!roverId || !durationMs) return;
|
||||||
|
driveCooldowns.set(roverId, Date.now() + durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
issueCommand,
|
issueCommand,
|
||||||
handleAck,
|
handleAck,
|
||||||
|
getRecentDriveActivity,
|
||||||
|
setDriveCooldown,
|
||||||
};
|
};
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
@@ -47,6 +68,29 @@ io.on('connection', (socket) => {
|
|||||||
throw new Error('Not your turn or no control');
|
throw new Error('Not your turn or no control');
|
||||||
}
|
}
|
||||||
const payload = data ? { ...data } : {};
|
const payload = data ? { ...data } : {};
|
||||||
|
const isAdminSocket = isAdmin(socket);
|
||||||
|
const driveDirect = payload?.driveDirect;
|
||||||
|
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||||
|
const left = Number(driveDirect.left);
|
||||||
|
const right = Number(driveDirect.right);
|
||||||
|
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||||
|
const blockedUntil = driveCooldowns.get(roverId);
|
||||||
|
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||||
|
throw new Error('Drive blocked: dock protection cooldown');
|
||||||
|
}
|
||||||
|
if (speed > 0) {
|
||||||
|
let direction = 'turn';
|
||||||
|
if (left > 0 && right > 0) direction = 'forward';
|
||||||
|
if (left < 0 && right < 0) direction = 'backward';
|
||||||
|
lastDriveActivity.set(roverId, {
|
||||||
|
ts: Date.now(),
|
||||||
|
socketId: socket.id,
|
||||||
|
direction,
|
||||||
|
speed,
|
||||||
|
isAdmin: isAdminSocket,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const id = issueCommand(roverId, { type, ...payload });
|
const id = issueCommand(roverId, { type, ...payload });
|
||||||
logger.info('Queued command', socket.id, roverId, type);
|
logger.info('Queued command', socket.id, roverId, type);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -24,12 +24,7 @@ function enforceLockdown() {
|
|||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
socket.data.lockdownTimer = setTimeout(() => {
|
disconnectForLockdown(socket);
|
||||||
if (!isLockdownAdmin(socket)) {
|
|
||||||
disconnectForLockdown(socket);
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
socket.once('disconnect', () => clearLockdownTimer(socket));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const { sendAlert, COLORS } = require('./alertService');
|
const { sendAlert } = require('./alertService');
|
||||||
|
const ALERT_COLOR = '#ff9800';
|
||||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||||
const { publishEvent } = require('./eventBus');
|
const { publishEvent } = require('./eventBus');
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ function setMode(nextMode, socket, options = {}) {
|
|||||||
}
|
}
|
||||||
currentMode = nextMode;
|
currentMode = nextMode;
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: COLORS.info,
|
color: ALERT_COLOR,
|
||||||
title: 'Mode Changed',
|
title: 'Mode Changed',
|
||||||
message: `Server mode set to ${nextMode}`,
|
message: `Server mode set to ${nextMode}`,
|
||||||
});
|
});
|
||||||
@@ -65,7 +66,7 @@ io.on('connection', (socket) => {
|
|||||||
try {
|
try {
|
||||||
setMode(mode, socket);
|
setMode(mode, socket);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message });
|
sendAlert({ color: ALERT_COLOR, title: 'Mode change failed', message: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ function sanitizeNickname(raw) {
|
|||||||
if (typeof raw !== 'string') return '';
|
if (typeof raw !== 'string') return '';
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
return trimmed.slice(0, 32);
|
const sanitized = trimmed.replace(/\*/g, 'nope');
|
||||||
|
return sanitized.slice(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNickname(socket) {
|
function getNickname(socket) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -14,15 +14,16 @@ function normalizeCamera(camera) {
|
|||||||
logger.warn('Room camera missing id', camera);
|
logger.warn('Room camera missing id', camera);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!camera.url) {
|
if (!camera.url && !camera.streamUrl && !camera.mjpegUrl) {
|
||||||
logger.warn('Room camera missing url', { id, camera });
|
logger.warn('Room camera missing url/streamUrl', { id, camera });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: String(id),
|
id: String(id),
|
||||||
name: camera.name || camera.id || String(id),
|
name: camera.name || camera.id || String(id),
|
||||||
description: camera.description || null,
|
description: camera.description || null,
|
||||||
url: camera.url,
|
url: camera.url || null,
|
||||||
|
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,149 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 500; // 2 fps target
|
const POLL_INTERVAL_MS = 67;
|
||||||
const FETCH_TIMEOUT_MS = 2000;
|
const FETCH_TIMEOUT_MS = 2000;
|
||||||
const STALE_AFTER_MS = 4000;
|
const STREAM_RETRY_MS = 1500;
|
||||||
|
|
||||||
const cameraState = new Map(); // id -> {frame, ts, stale, error, failures, fetching}
|
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
|
||||||
const events = new EventEmitter(); // frame, status
|
const events = new EventEmitter(); // frame, status
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
const streamState = new Map(); // id -> { req, reconnectTimer }
|
||||||
|
const JPEG_START = Buffer.from([0xff, 0xd8]);
|
||||||
|
const JPEG_END = Buffer.from([0xff, 0xd9]);
|
||||||
|
|
||||||
function markState(id, updates = {}) {
|
function markState(id, updates = {}) {
|
||||||
const prev = cameraState.get(id) || {};
|
const prev = cameraState.get(id) || {};
|
||||||
const next = { ...prev, ...updates };
|
const next = { ...prev, ...updates };
|
||||||
if (next.ts != null) {
|
|
||||||
next.stale = Date.now() - next.ts > STALE_AFTER_MS || !!next.stale;
|
|
||||||
} else {
|
|
||||||
next.stale = true;
|
|
||||||
}
|
|
||||||
cameraState.set(id, next);
|
cameraState.set(id, next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMjpegUrl(rawUrl) {
|
||||||
|
if (!rawUrl) return false;
|
||||||
|
const lower = String(rawUrl).toLowerCase();
|
||||||
|
return lower.endsWith('.mjpg') || lower.endsWith('.mjpeg') || lower.includes('mjpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStreamUrl(camera) {
|
||||||
|
if (camera.streamUrl) return camera.streamUrl;
|
||||||
|
if (isMjpegUrl(camera.url)) return camera.url;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream(id) {
|
||||||
|
const entry = streamState.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.req) {
|
||||||
|
entry.req.destroy();
|
||||||
|
}
|
||||||
|
if (entry.reconnectTimer) {
|
||||||
|
clearTimeout(entry.reconnectTimer);
|
||||||
|
}
|
||||||
|
streamState.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStreamReconnect(camera) {
|
||||||
|
const id = camera.id;
|
||||||
|
const entry = streamState.get(id) || {};
|
||||||
|
if (entry.reconnectTimer) return;
|
||||||
|
entry.reconnectTimer = setTimeout(() => {
|
||||||
|
const current = streamState.get(id) || {};
|
||||||
|
current.reconnectTimer = null;
|
||||||
|
streamState.set(id, current);
|
||||||
|
startStream(camera);
|
||||||
|
}, STREAM_RETRY_MS);
|
||||||
|
streamState.set(id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStreamRequest(id, req) {
|
||||||
|
const entry = streamState.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.req !== req) return;
|
||||||
|
entry.req = null;
|
||||||
|
streamState.set(id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStreamError(camera, err) {
|
||||||
|
const state = cameraState.get(camera.id) || {};
|
||||||
|
const failures = (state.failures || 0) + 1;
|
||||||
|
markState(camera.id, { error: err.message || 'stream error', failures });
|
||||||
|
events.emit('status', { id: camera.id, error: err.message || 'stream error' });
|
||||||
|
logger.warn('Stream failed', { id: camera.id, err: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStream(camera) {
|
||||||
|
const streamUrl = getStreamUrl(camera);
|
||||||
|
if (!streamUrl) return;
|
||||||
|
if (streamState.get(camera.id)?.req) return;
|
||||||
|
const url = new URL(streamUrl);
|
||||||
|
const client = url.protocol === 'https:' ? https : http;
|
||||||
|
const req = client.get(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: `${url.pathname}${url.search}`,
|
||||||
|
headers: { Accept: 'multipart/x-mixed-replace' },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
clearStreamRequest(camera.id, req);
|
||||||
|
handleStreamError(camera, new Error(`HTTP ${res.statusCode}`));
|
||||||
|
scheduleStreamReconnect(camera);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
while (true) {
|
||||||
|
let start = buffer.indexOf(JPEG_START);
|
||||||
|
if (start === -1) {
|
||||||
|
if (buffer.length > 2 * 1024 * 1024) {
|
||||||
|
buffer = buffer.slice(-1024 * 1024);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (start > 0) {
|
||||||
|
buffer = buffer.slice(start);
|
||||||
|
start = 0;
|
||||||
|
}
|
||||||
|
const end = buffer.indexOf(JPEG_END, 2);
|
||||||
|
if (end === -1) break;
|
||||||
|
const frame = buffer.slice(0, end + 2);
|
||||||
|
buffer = buffer.slice(end + 2);
|
||||||
|
const ts = Date.now();
|
||||||
|
markState(camera.id, { frame, ts, error: null, failures: 0 });
|
||||||
|
events.emit('frame', { id: camera.id, buffer: frame, ts });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
clearStreamRequest(camera.id, req);
|
||||||
|
scheduleStreamReconnect(camera);
|
||||||
|
});
|
||||||
|
res.on('aborted', () => {
|
||||||
|
clearStreamRequest(camera.id, req);
|
||||||
|
handleStreamError(camera, new Error('stream aborted'));
|
||||||
|
scheduleStreamReconnect(camera);
|
||||||
|
});
|
||||||
|
res.on('error', (err) => {
|
||||||
|
clearStreamRequest(camera.id, req);
|
||||||
|
handleStreamError(camera, err);
|
||||||
|
scheduleStreamReconnect(camera);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', (err) => {
|
||||||
|
clearStreamRequest(camera.id, req);
|
||||||
|
handleStreamError(camera, err);
|
||||||
|
scheduleStreamReconnect(camera);
|
||||||
|
});
|
||||||
|
streamState.set(camera.id, { req, reconnectTimer: null });
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSnapshot(camera) {
|
async function fetchSnapshot(camera) {
|
||||||
const { id, url } = camera;
|
const { id, url } = camera;
|
||||||
const state = cameraState.get(id);
|
const state = cameraState.get(id);
|
||||||
@@ -37,8 +159,8 @@ async function fetchSnapshot(camera) {
|
|||||||
const arrayBuffer = await res.arrayBuffer();
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const next = markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
||||||
events.emit('frame', { id, buffer, ts, stale: !!next.stale });
|
events.emit('frame', { id, buffer, ts });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const failures = (state?.failures || 0) + 1;
|
const failures = (state?.failures || 0) + 1;
|
||||||
markState(id, { error: err.message, failures });
|
markState(id, { error: err.message, failures });
|
||||||
@@ -55,26 +177,34 @@ function stopAll() {
|
|||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
|
Array.from(streamState.keys()).forEach((id) => stopStream(id));
|
||||||
cameraState.clear();
|
cameraState.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAll() {
|
function startAll() {
|
||||||
stopAll();
|
stopAll();
|
||||||
pollTimer = setInterval(() => {
|
const cameras = getRoomCameras();
|
||||||
getRoomCameras().forEach((camera) => fetchSnapshot(camera));
|
const snapshotCameras = cameras.filter((camera) => !getStreamUrl(camera) && camera.url);
|
||||||
}, POLL_INTERVAL_MS);
|
cameras.forEach((camera) => startStream(camera));
|
||||||
getRoomCameras().forEach((camera) => fetchSnapshot(camera));
|
if (snapshotCameras.length) {
|
||||||
logger.info('Started snapshot polling', { count: getRoomCameras().length });
|
pollTimer = setInterval(() => {
|
||||||
|
snapshotCameras.forEach((camera) => fetchSnapshot(camera));
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
snapshotCameras.forEach((camera) => fetchSnapshot(camera));
|
||||||
|
}
|
||||||
|
logger.info('Started room camera feeds', {
|
||||||
|
total: cameras.length,
|
||||||
|
streaming: cameras.length - snapshotCameras.length,
|
||||||
|
snapshots: snapshotCameras.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(id) {
|
function getState(id) {
|
||||||
const state = cameraState.get(id);
|
const state = cameraState.get(id);
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
const stale = state.ts == null || Date.now() - state.ts > STALE_AFTER_MS;
|
|
||||||
return {
|
return {
|
||||||
frame: state.frame || null,
|
frame: state.frame || null,
|
||||||
ts: state.ts || null,
|
ts: state.ts || null,
|
||||||
stale,
|
|
||||||
error: state.error || null,
|
error: state.error || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnap
|
|||||||
|
|
||||||
const SUBSCRIBE_LIMIT = 50;
|
const SUBSCRIBE_LIMIT = 50;
|
||||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||||
|
const STREAM_INTERVAL_MS = 800;
|
||||||
|
|
||||||
function passesMode(socket) {
|
function passesMode(socket) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
@@ -27,6 +28,7 @@ function canViewRoomCamera(socket) {
|
|||||||
const cameraSubscribers = new Map(); // id -> Set(socketId)
|
const cameraSubscribers = new Map(); // id -> Set(socketId)
|
||||||
const socketSubscriptions = new Map(); // socketId -> Set(id)
|
const socketSubscriptions = new Map(); // socketId -> Set(id)
|
||||||
const subscribeBuckets = new Map(); // socketId -> { start, count }
|
const subscribeBuckets = new Map(); // socketId -> { start, count }
|
||||||
|
const lastSentBySocket = new Map(); // socketId -> Map(cameraId -> ts)
|
||||||
|
|
||||||
function addSubscription(socket, cameraId) {
|
function addSubscription(socket, cameraId) {
|
||||||
if (!cameraSubscribers.has(cameraId)) {
|
if (!cameraSubscribers.has(cameraId)) {
|
||||||
@@ -82,13 +84,24 @@ function sendStatus(socket, cameraId, status) {
|
|||||||
socket.emit('roomCamera:status', { id: cameraId, ...status });
|
socket.emit('roomCamera:status', { id: cameraId, ...status });
|
||||||
}
|
}
|
||||||
|
|
||||||
roomCameraStreamEvents.on('frame', ({ id, buffer, ts, stale }) => {
|
roomCameraStreamEvents.on('frame', ({ id, buffer, ts }) => {
|
||||||
const bucket = cameraSubscribers.get(id);
|
const bucket = cameraSubscribers.get(id);
|
||||||
if (!bucket || !buffer) return;
|
if (!bucket || !buffer) return;
|
||||||
bucket.forEach((socketId) => {
|
bucket.forEach((socketId) => {
|
||||||
const socket = io.sockets.sockets.get(socketId);
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
sendFrame(socket, id, { ts, stale: !!stale }, buffer);
|
let lastMap = lastSentBySocket.get(socketId);
|
||||||
|
if (!lastMap) {
|
||||||
|
lastMap = new Map();
|
||||||
|
lastSentBySocket.set(socketId, lastMap);
|
||||||
|
}
|
||||||
|
const lastSent = lastMap.get(id) || 0;
|
||||||
|
const now = ts || Date.now();
|
||||||
|
if (now - lastSent < STREAM_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMap.set(id, now);
|
||||||
|
sendFrame(socket, id, { ts }, buffer);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,11 +136,10 @@ io.on('connection', (socket) => {
|
|||||||
validIds.forEach((cameraId) => {
|
validIds.forEach((cameraId) => {
|
||||||
const state = getRoomCameraState(cameraId);
|
const state = getRoomCameraState(cameraId);
|
||||||
if (state?.frame) {
|
if (state?.frame) {
|
||||||
sendFrame(socket, cameraId, { ts: state.ts, stale: !!state.stale }, state.frame);
|
sendFrame(socket, cameraId, { ts: state.ts }, state.frame);
|
||||||
}
|
}
|
||||||
sendStatus(socket, cameraId, {
|
sendStatus(socket, cameraId, {
|
||||||
ts: state?.ts || null,
|
ts: state?.ts || null,
|
||||||
stale: state?.stale ?? true,
|
|
||||||
error: state?.error || null,
|
error: state?.error || null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -150,5 +162,6 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
removeAllSubscriptions(socket.id);
|
removeAllSubscriptions(socket.id);
|
||||||
subscribeBuckets.delete(socket.id);
|
subscribeBuckets.delete(socket.id);
|
||||||
|
lastSentBySocket.delete(socket.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
const roverWSS = require('../globals/ws');
|
const roverWSS = require('../globals/ws');
|
||||||
const logger = require('../globals/logger').child('roverConnection');
|
const logger = require('../globals/logger').child('roverConnection');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { sendAlert, COLORS } = require('./alertService');
|
const { sendAlert } = require('./alertService');
|
||||||
|
const ALERT_COLOR = '#00bcd4';
|
||||||
const { handleAck } = require('./commandService');
|
const { handleAck } = require('./commandService');
|
||||||
|
|
||||||
function handleMessage(roverId, msg) {
|
function handleMessage(roverId, msg) {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'hello':
|
case 'hello':
|
||||||
roverManager.upsertRover(msg, this);
|
roverManager.upsertRover(msg, this);
|
||||||
sendAlert({ color: COLORS.info, title: 'Rover Connected', message: roverId });
|
sendAlert({ color: ALERT_COLOR, title: 'Rover Connected', message: roverId });
|
||||||
break;
|
break;
|
||||||
case 'sensor':
|
case 'sensor':
|
||||||
roverManager.handleSensorFrame(roverId, msg);
|
roverManager.handleSensorFrame(roverId, msg);
|
||||||
break;
|
break;
|
||||||
case 'event':
|
case 'event':
|
||||||
sendAlert({ color: COLORS.info, title: `${roverId} event`, message: msg.event });
|
sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -40,7 +41,7 @@ roverWSS.on('connection', (ws) => {
|
|||||||
});
|
});
|
||||||
roverManager.upsertRover(msg, ws);
|
roverManager.upsertRover(msg, ws);
|
||||||
roverManager.broadcastRoster();
|
roverManager.broadcastRoster();
|
||||||
sendAlert({ color: COLORS.success, title: 'Rover Online', message: roverId });
|
sendAlert({ color: ALERT_COLOR, title: 'Rover Online', message: roverId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
@@ -49,14 +50,14 @@ roverWSS.on('connection', (ws) => {
|
|||||||
} else if (msg.type === 'ack') {
|
} else if (msg.type === 'ack') {
|
||||||
handleAck(msg);
|
handleAck(msg);
|
||||||
} else if (msg.type === 'event') {
|
} else if (msg.type === 'event') {
|
||||||
sendAlert({ color: COLORS.info, title: `${roverId}`, message: msg.event });
|
sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
if (roverId) {
|
if (roverId) {
|
||||||
roverManager.removeRover(roverId);
|
roverManager.removeRover(roverId);
|
||||||
sendAlert({ color: COLORS.warn, title: 'Rover Offline', message: roverId });
|
sendAlert({ color: ALERT_COLOR, title: 'Rover Offline', message: roverId });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const logger = require('../globals/logger').child('roverManager');
|
const logger = require('../globals/logger').child('roverManager');
|
||||||
const { sendAlert, COLORS } = require('./alertService');
|
const { sendAlert } = require('./alertService');
|
||||||
|
const ALERT_COLOR = '#8bc34a';
|
||||||
const { parseSensorFrame } = require('../helpers/sensorDecoder');
|
const { parseSensorFrame } = require('../helpers/sensorDecoder');
|
||||||
const { MODES, getMode } = require('./modeManager');
|
const { MODES, getMode } = require('./modeManager');
|
||||||
const { isAdmin, roleEvents } = require('./roleService');
|
const { isAdmin, roleEvents } = require('./roleService');
|
||||||
const { publishEvent } = require('./eventBus');
|
const { publishEvent } = require('./eventBus');
|
||||||
|
const videoSessions = require('./videoSessions');
|
||||||
|
|
||||||
const rovers = new Map(); // roverId -> record
|
const rovers = new Map(); // roverId -> record
|
||||||
const socketToRovers = new Map(); // socketId -> Set(roverId)
|
const socketToRovers = new Map(); // socketId -> Set(roverId)
|
||||||
const spectatorSockets = new Set();
|
const spectatorSockets = new Set();
|
||||||
const turnService = require('./turnService');
|
const turnService = require('./turnService');
|
||||||
const managerEvents = new EventEmitter();
|
const managerEvents = new EventEmitter();
|
||||||
|
const DOCK_GUARD_WINDOW_MS = 2 * 1000;
|
||||||
|
const IDLE_UNDOCKED_MS = 2 * 60 * 1000;
|
||||||
|
const PASSIVE_UNDOCKED_MS = 60 * 1000;
|
||||||
|
const DOCK_GUARD_RETRY_MS = 10 * 1000;
|
||||||
|
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
||||||
|
const BACKOFF_MS = 500;
|
||||||
|
const BACKOFF_SPEED = 300;
|
||||||
|
const backoffTimers = new Map(); // roverId -> Timeout
|
||||||
|
const dockGuardStates = new Map(); // roverId -> guard state
|
||||||
|
|
||||||
function ensureRecord(id) {
|
function ensureRecord(id) {
|
||||||
if (!rovers.has(id)) {
|
if (!rovers.has(id)) {
|
||||||
@@ -20,12 +31,15 @@ function ensureRecord(id) {
|
|||||||
meta: null,
|
meta: null,
|
||||||
ws: null,
|
ws: null,
|
||||||
lastSensor: null,
|
lastSensor: null,
|
||||||
|
docked: null,
|
||||||
|
lastBumpAt: null,
|
||||||
drivers: new Set(),
|
drivers: new Set(),
|
||||||
locked: false,
|
locked: false,
|
||||||
lockReason: null,
|
lockReason: null,
|
||||||
batteryState: null,
|
batteryState: null,
|
||||||
room: `rover:${id}`,
|
room: `rover:${id}`,
|
||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
|
lastMovementAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return rovers.get(id);
|
return rovers.get(id);
|
||||||
@@ -60,6 +74,7 @@ function removeRover(id) {
|
|||||||
const record = rovers.get(id);
|
const record = rovers.get(id);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
rovers.delete(id);
|
rovers.delete(id);
|
||||||
|
stopDockGuard(id);
|
||||||
turnService.cleanupRover(id);
|
turnService.cleanupRover(id);
|
||||||
spectatorSockets.forEach((socketId) => {
|
spectatorSockets.forEach((socketId) => {
|
||||||
const sock = io.sockets.sockets.get(socketId);
|
const sock = io.sockets.sockets.get(socketId);
|
||||||
@@ -82,7 +97,7 @@ function lockRover(id, locked, options = {}) {
|
|||||||
record.lockReason = reason;
|
record.lockReason = reason;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: COLORS.warn,
|
color: ALERT_COLOR,
|
||||||
title: 'Rover Locked',
|
title: 'Rover Locked',
|
||||||
message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`,
|
message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`,
|
||||||
});
|
});
|
||||||
@@ -96,7 +111,7 @@ function lockRover(id, locked, options = {}) {
|
|||||||
record.locked = false;
|
record.locked = false;
|
||||||
record.lockReason = null;
|
record.lockReason = null;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` });
|
sendAlert({ color: ALERT_COLOR, title: 'Rover Unlocked', message: `${id} unlocked.` });
|
||||||
}
|
}
|
||||||
publishEvent({
|
publishEvent({
|
||||||
source: 'roverManager',
|
source: 'roverManager',
|
||||||
@@ -119,6 +134,7 @@ function getRoster() {
|
|||||||
media: record.meta?.media,
|
media: record.meta?.media,
|
||||||
cameraServo: record.meta?.cameraServo,
|
cameraServo: record.meta?.cameraServo,
|
||||||
audio: record.meta?.audio,
|
audio: record.meta?.audio,
|
||||||
|
nightVision: record.meta?.nightVision,
|
||||||
locked: record.locked,
|
locked: record.locked,
|
||||||
lockReason: record.lockReason,
|
lockReason: record.lockReason,
|
||||||
lastSeen: record.lastSeen,
|
lastSeen: record.lastSeen,
|
||||||
@@ -169,12 +185,218 @@ function handleSensorFrame(roverId, frame) {
|
|||||||
const decoded = parseSensorFrame(frame.data);
|
const decoded = parseSensorFrame(frame.data);
|
||||||
record.lastSensor = { raw: frame, decoded };
|
record.lastSensor = { raw: frame, decoded };
|
||||||
record.batteryState = computeBatteryState(record, decoded);
|
record.batteryState = computeBatteryState(record, decoded);
|
||||||
io.to(record.room).emit('sensorFrame', {
|
updateMovement(record, decoded);
|
||||||
|
const hasDockInfo = decoded?.chargingSources != null;
|
||||||
|
if (hasDockInfo) {
|
||||||
|
const prevDocked = record.docked;
|
||||||
|
const docked = Boolean(decoded?.chargingSources?.homeBase);
|
||||||
|
record.docked = docked;
|
||||||
|
if (prevDocked === true && docked === false) {
|
||||||
|
handleIdleUndock(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bumps = decoded?.bumpsAndWheelDrops;
|
||||||
|
if (bumps?.bumpLeft || bumps?.bumpRight) {
|
||||||
|
record.lastBumpAt = Date.now();
|
||||||
|
}
|
||||||
|
io.to(record.room).volatile.emit('sensorFrame', {
|
||||||
roverId,
|
roverId,
|
||||||
frame,
|
frame,
|
||||||
sensors: decoded,
|
sensors: decoded,
|
||||||
});
|
});
|
||||||
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
|
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
|
||||||
|
evaluateDockGuard(record, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMovement(record, sensors) {
|
||||||
|
if (!record || !sensors) return;
|
||||||
|
const distance = Math.abs(sensors.distanceMm ?? 0);
|
||||||
|
const angle = Math.abs(sensors.angleDeg ?? 0);
|
||||||
|
const requested = Math.abs(sensors.requestedVelocity ?? 0);
|
||||||
|
const requestedLeft = Math.abs(sensors.requestedLeftVelocity ?? 0);
|
||||||
|
const requestedRight = Math.abs(sensors.requestedRightVelocity ?? 0);
|
||||||
|
const moving = distance > 0 || angle > 0 || requested > 0 || requestedLeft > 0 || requestedRight > 0;
|
||||||
|
if (moving) {
|
||||||
|
record.lastMovementAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDockGuardState(roverId) {
|
||||||
|
if (!dockGuardStates.has(roverId)) {
|
||||||
|
dockGuardStates.set(roverId, {
|
||||||
|
idleUndockedSince: null,
|
||||||
|
passiveUndockedSince: null,
|
||||||
|
active: false,
|
||||||
|
reason: null,
|
||||||
|
startedAt: null,
|
||||||
|
timer: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dockGuardStates.get(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateDockGuard(record, sensors) {
|
||||||
|
if (!record || !sensors) return;
|
||||||
|
const state = getDockGuardState(record.id);
|
||||||
|
const now = Date.now();
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
|
const oiMode = sensors?.oiMode?.label || null;
|
||||||
|
const idleMs = record.lastMovementAt ? now - record.lastMovementAt : 0;
|
||||||
|
const isIdle = idleMs >= 1000;
|
||||||
|
|
||||||
|
if (docked || record.drivers.size > 0 || !isIdle) {
|
||||||
|
state.idleUndockedSince = null;
|
||||||
|
} else if (!state.idleUndockedSince) {
|
||||||
|
state.idleUndockedSince = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docked || oiMode !== 'passive' || !isIdle) {
|
||||||
|
state.passiveUndockedSince = null;
|
||||||
|
} else if (!state.passiveUndockedSince) {
|
||||||
|
state.passiveUndockedSince = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.active) {
|
||||||
|
const shouldStop =
|
||||||
|
docked ||
|
||||||
|
!isIdle ||
|
||||||
|
(state.reason === 'idle' && record.drivers.size > 0) ||
|
||||||
|
(state.reason === 'passive' && oiMode !== 'passive');
|
||||||
|
if (shouldStop) {
|
||||||
|
stopDockGuard(record.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleReady =
|
||||||
|
state.idleUndockedSince && now - state.idleUndockedSince >= IDLE_UNDOCKED_MS;
|
||||||
|
const passiveReady =
|
||||||
|
state.passiveUndockedSince && now - state.passiveUndockedSince >= PASSIVE_UNDOCKED_MS;
|
||||||
|
|
||||||
|
if (passiveReady) {
|
||||||
|
startDockGuard(record, 'passive', idleMs);
|
||||||
|
} else if (idleReady) {
|
||||||
|
startDockGuard(record, 'idle', idleMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDockGuard(record, reason, idleMs) {
|
||||||
|
if (!record) return;
|
||||||
|
const state = getDockGuardState(record.id);
|
||||||
|
if (state.active) return;
|
||||||
|
state.active = true;
|
||||||
|
state.reason = reason;
|
||||||
|
state.startedAt = Date.now();
|
||||||
|
const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked';
|
||||||
|
sendAlert({
|
||||||
|
color: ALERT_COLOR,
|
||||||
|
title: 'Dock Guard Triggered',
|
||||||
|
message: `${record.id} ${reasonText}. Seeking dock and restarting sensors until movement.`,
|
||||||
|
});
|
||||||
|
publishEvent({
|
||||||
|
source: 'roverManager',
|
||||||
|
type: 'rover.dockGuard',
|
||||||
|
payload: {
|
||||||
|
roverId: record.id,
|
||||||
|
reason,
|
||||||
|
reasonText,
|
||||||
|
idleMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
attemptDockGuard(record.id);
|
||||||
|
state.timer = setInterval(() => attemptDockGuard(record.id), DOCK_GUARD_RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDockGuard(roverId) {
|
||||||
|
const state = dockGuardStates.get(roverId);
|
||||||
|
if (!state) return;
|
||||||
|
if (state.timer) {
|
||||||
|
clearInterval(state.timer);
|
||||||
|
}
|
||||||
|
state.active = false;
|
||||||
|
state.reason = null;
|
||||||
|
state.startedAt = null;
|
||||||
|
state.timer = null;
|
||||||
|
state.idleUndockedSince = null;
|
||||||
|
state.passiveUndockedSince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptDockGuard(roverId) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) {
|
||||||
|
stopDockGuard(roverId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { issueCommand } = require('./commandService');
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, { type: 'sensorStream', sensorStream: { enable: true } });
|
||||||
|
issueCommand(roverId, { type: 'raw', raw: DOCK_COMMAND_BASE64 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock guard command failed', roverId, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleIdleUndock(undockedRecord) {
|
||||||
|
if (!undockedRecord || undockedRecord.drivers.size > 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const { getRecentDriveActivity, setDriveCooldown, issueCommand } = require('./commandService');
|
||||||
|
const candidates = getRecentDriveActivity(DOCK_GUARD_WINDOW_MS, { excludeAdmins: true })
|
||||||
|
.filter((candidate) => candidate.roverId !== undockedRecord.id);
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
const candidatesWithBump = candidates.filter((candidate) => {
|
||||||
|
const record = rovers.get(candidate.roverId);
|
||||||
|
return record?.lastBumpAt && now - record.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
|
||||||
|
});
|
||||||
|
const pool = candidatesWithBump.length > 0 ? candidatesWithBump : candidates;
|
||||||
|
pool.sort((a, b) => b.ts - a.ts);
|
||||||
|
const suspect = pool[0];
|
||||||
|
if (!suspect) return;
|
||||||
|
const suspectRecord = rovers.get(suspect.roverId);
|
||||||
|
if (!suspectRecord) return;
|
||||||
|
const bumpRecent =
|
||||||
|
suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
|
||||||
|
sendAlert({
|
||||||
|
color: ALERT_COLOR,
|
||||||
|
title: 'Dock protection',
|
||||||
|
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}.`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||||
|
issueCommand(suspect.roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock protection stop failed', suspect.roverId, err.message);
|
||||||
|
}
|
||||||
|
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
|
||||||
|
if (bumpRecent) {
|
||||||
|
nudgeRover(suspect.roverId, 'backward');
|
||||||
|
} else {
|
||||||
|
nudgeRover(suspect.roverId, 'forward');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeRover(roverId, direction = 'backward') {
|
||||||
|
if (!roverId) return;
|
||||||
|
const { issueCommand } = require('./commandService');
|
||||||
|
clearTimeout(backoffTimers.get(roverId));
|
||||||
|
const speed = direction === 'forward' ? BACKOFF_SPEED : -BACKOFF_SPEED;
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, {
|
||||||
|
type: 'drive',
|
||||||
|
driveDirect: { left: speed, right: speed },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock protection nudge failed', roverId, err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backoffTimers.set(
|
||||||
|
roverId,
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Dock protection backoff stop failed', roverId, err.message);
|
||||||
|
}
|
||||||
|
}, BACKOFF_MS),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSocket(socket) {
|
function removeSocket(socket) {
|
||||||
@@ -204,7 +426,7 @@ function requestControl(roverId, socket, options = {}) {
|
|||||||
if (!allowUser && !isAdmin(socket)) {
|
if (!allowUser && !isAdmin(socket)) {
|
||||||
throw new Error('Only admins can request control');
|
throw new Error('Only admins can request control');
|
||||||
}
|
}
|
||||||
if (record.locked && !isAdmin(socket) && !allowUser) {
|
if (record.locked && !isAdmin(socket)) {
|
||||||
throw new Error('Rover locked');
|
throw new Error('Rover locked');
|
||||||
}
|
}
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
@@ -224,7 +446,7 @@ function requestControl(roverId, socket, options = {}) {
|
|||||||
socket.emit('controlGranted', { roverId });
|
socket.emit('controlGranted', { roverId });
|
||||||
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: COLORS.success,
|
color: ALERT_COLOR,
|
||||||
title: 'Control Granted',
|
title: 'Control Granted',
|
||||||
message: `${socket.id} now driving ${roverId}`,
|
message: `${socket.id} now driving ${roverId}`,
|
||||||
});
|
});
|
||||||
@@ -275,6 +497,41 @@ function getPrimaryRoverForSocket(socketId) {
|
|||||||
return first.done ? null : first.value;
|
return first.done ? null : first.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDockedAndCharging(record) {
|
||||||
|
const sensors = record?.lastSensor?.decoded || record?.lastSensor?.sensors || null;
|
||||||
|
if (!sensors) return false;
|
||||||
|
const docked = Boolean(sensors.chargingSources?.homeBase);
|
||||||
|
const code = sensors.chargingState?.code;
|
||||||
|
const charging = code === 2 || code === 3 || code === 4;
|
||||||
|
return docked && charging;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOtherDrivers(record, socketId) {
|
||||||
|
if (!record) return false;
|
||||||
|
for (const driver of record.drivers) {
|
||||||
|
if (driver !== socketId) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSwitchRover(socket, targetRoverId) {
|
||||||
|
const currentId = getPrimaryRoverForSocket(socket.id);
|
||||||
|
if (!currentId || currentId === targetRoverId) {
|
||||||
|
return { ok: true, currentId };
|
||||||
|
}
|
||||||
|
const currentRecord = rovers.get(currentId);
|
||||||
|
if (!currentRecord) {
|
||||||
|
return { ok: true, currentId };
|
||||||
|
}
|
||||||
|
if (hasOtherDrivers(currentRecord, socket.id)) {
|
||||||
|
return { ok: true, currentId };
|
||||||
|
}
|
||||||
|
if (isDockedAndCharging(currentRecord)) {
|
||||||
|
return { ok: true, currentId };
|
||||||
|
}
|
||||||
|
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
upsertRover,
|
upsertRover,
|
||||||
removeRover,
|
removeRover,
|
||||||
@@ -311,17 +568,40 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
|
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
|
||||||
try {
|
try {
|
||||||
|
if (socket.data?.role === 'spectator') {
|
||||||
|
throw new Error('Spectators cannot drive');
|
||||||
|
}
|
||||||
const targetId = roverId || Array.from(rovers.keys())[0];
|
const targetId = roverId || Array.from(rovers.keys())[0];
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
throw new Error('No rovers available');
|
throw new Error('No rovers available');
|
||||||
}
|
}
|
||||||
|
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 });
|
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 });
|
socket.emit('controlGranted', { roverId: targetId });
|
||||||
cb({ success: true, roverId: targetId });
|
cb({ success: true, roverId: targetId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Request control failed', socket.id, err.message);
|
logger.warn('Request control failed', socket.id, err.message);
|
||||||
sendAlert({ color: COLORS.warn, title: 'Control denied', message: err.message });
|
sendAlert({ color: ALERT_COLOR, title: 'Control denied', message: err.message });
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +627,7 @@ io.on('connection', (socket) => {
|
|||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Lock change failed', roverId, err.message);
|
logger.warn('Lock change failed', roverId, err.message);
|
||||||
sendAlert({ color: COLORS.error, title: 'Lock failed', message: err.message });
|
sendAlert({ color: ALERT_COLOR, title: 'Lock failed', message: err.message });
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,6 +657,10 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('subscribeAll', handleSubscribeAll);
|
socket.on('subscribeAll', handleSubscribeAll);
|
||||||
socket.on('session:subscribeAll', handleSubscribeAll);
|
socket.on('session:subscribeAll', handleSubscribeAll);
|
||||||
|
|
||||||
|
socket.on('disconnecting', () => {
|
||||||
|
logger.info('Socket disconnecting', socket.id);
|
||||||
|
removeSocket(socket);
|
||||||
|
});
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
logger.info('Socket disconnected', socket.id);
|
logger.info('Socket disconnected', socket.id);
|
||||||
removeSocket(socket);
|
removeSocket(socket);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('../globals/logger').child('roverSnapshot');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
|
||||||
|
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||||
|
const POLL_INTERVAL_MS = 300;
|
||||||
|
|
||||||
|
const roverState = new Map(); // id -> { frame, ts, error, failures, fetching, mtimeMs }
|
||||||
|
const events = new EventEmitter(); // frame, status
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function markState(id, updates = {}) {
|
||||||
|
const prev = roverState.get(id) || {};
|
||||||
|
const next = { ...prev, ...updates };
|
||||||
|
roverState.set(id, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshotPath(id) {
|
||||||
|
return path.join(SNAPSHOT_DIR, `${id}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSnapshot(id) {
|
||||||
|
const state = roverState.get(id);
|
||||||
|
if (state?.fetching) return;
|
||||||
|
markState(id, { fetching: true });
|
||||||
|
try {
|
||||||
|
const filePath = getSnapshotPath(id);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
if (state?.mtimeMs && stats.mtimeMs <= state.mtimeMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = await fs.readFile(filePath);
|
||||||
|
const ts = stats.mtimeMs || Date.now();
|
||||||
|
markState(id, { frame: buffer, ts, error: null, failures: 0, mtimeMs: stats.mtimeMs });
|
||||||
|
events.emit('frame', { id, buffer, ts });
|
||||||
|
} catch (err) {
|
||||||
|
const failures = (state?.failures || 0) + 1;
|
||||||
|
const message = err.code === 'ENOENT' ? 'Snapshot missing' : err.message;
|
||||||
|
markState(id, { error: message, failures });
|
||||||
|
events.emit('status', { id, error: message });
|
||||||
|
if (failures % 20 === 1) {
|
||||||
|
logger.warn('Snapshot read failed', { id, err: message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
markState(id, { fetching: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupInactive(activeIds) {
|
||||||
|
roverState.forEach((_, id) => {
|
||||||
|
if (!activeIds.has(id)) {
|
||||||
|
roverState.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAll() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
roverState.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAll() {
|
||||||
|
stopAll();
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
const roster = roverManager.getRoster();
|
||||||
|
const activeIds = new Set(roster.map((entry) => String(entry.id)));
|
||||||
|
cleanupInactive(activeIds);
|
||||||
|
roster.forEach((entry) => fetchSnapshot(String(entry.id)));
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
logger.info('Started rover snapshot polling');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState(id) {
|
||||||
|
const state = roverState.get(id);
|
||||||
|
if (!state) return null;
|
||||||
|
return {
|
||||||
|
frame: state.frame || null,
|
||||||
|
ts: state.ts || null,
|
||||||
|
error: state.error || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
startAll();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
roverSnapshotEvents: events,
|
||||||
|
getRoverSnapshotState: getState,
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('roverSnapshotSocket');
|
||||||
|
const { getMode, MODES } = require('./modeManager');
|
||||||
|
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { roverSnapshotEvents, getRoverSnapshotState } = require('./roverSnapshotService');
|
||||||
|
|
||||||
|
const SUBSCRIBE_LIMIT = 50;
|
||||||
|
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||||
|
const STREAM_INTERVAL_MS = 333;
|
||||||
|
|
||||||
|
function passesMode(socket) {
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode === MODES.LOCKDOWN) {
|
||||||
|
return isLockdownAdmin(socket);
|
||||||
|
}
|
||||||
|
if (mode === MODES.ADMIN) {
|
||||||
|
const role = getRole(socket);
|
||||||
|
return role === 'spectator' || isAdmin(socket);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canViewSnapshots(socket) {
|
||||||
|
return passesMode(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roverSubscribers = new Map(); // id -> Set(socketId)
|
||||||
|
const socketSubscriptions = new Map(); // socketId -> Set(id)
|
||||||
|
const subscribeBuckets = new Map(); // socketId -> { start, count }
|
||||||
|
const lastSentBySocket = new Map(); // socketId -> Map(roverId -> ts)
|
||||||
|
|
||||||
|
function addSubscription(socket, roverId) {
|
||||||
|
if (!roverSubscribers.has(roverId)) {
|
||||||
|
roverSubscribers.set(roverId, new Set());
|
||||||
|
}
|
||||||
|
roverSubscribers.get(roverId).add(socket.id);
|
||||||
|
|
||||||
|
if (!socketSubscriptions.has(socket.id)) {
|
||||||
|
socketSubscriptions.set(socket.id, new Set());
|
||||||
|
}
|
||||||
|
socketSubscriptions.get(socket.id).add(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSubscription(socketId, roverId) {
|
||||||
|
const bucket = roverSubscribers.get(roverId);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.delete(socketId);
|
||||||
|
if (bucket.size === 0) {
|
||||||
|
roverSubscribers.delete(roverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const socketBucket = socketSubscriptions.get(socketId);
|
||||||
|
if (socketBucket) {
|
||||||
|
socketBucket.delete(roverId);
|
||||||
|
if (socketBucket.size === 0) {
|
||||||
|
socketSubscriptions.delete(socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllSubscriptions(socketId) {
|
||||||
|
const bucket = socketSubscriptions.get(socketId);
|
||||||
|
if (!bucket) return;
|
||||||
|
bucket.forEach((roverId) => removeSubscription(socketId, roverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowSubscribe(socketId) {
|
||||||
|
const now = Date.now();
|
||||||
|
let bucket = subscribeBuckets.get(socketId);
|
||||||
|
if (!bucket || now - bucket.start >= SUBSCRIBE_WINDOW_MS) {
|
||||||
|
bucket = { start: now, count: 0 };
|
||||||
|
}
|
||||||
|
bucket.count += 1;
|
||||||
|
subscribeBuckets.set(socketId, bucket);
|
||||||
|
return bucket.count <= SUBSCRIBE_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFrame(socket, roverId, payload, buffer) {
|
||||||
|
socket.emit('roverSnapshot:frame', { id: roverId, ...payload }, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendStatus(socket, roverId, status) {
|
||||||
|
socket.emit('roverSnapshot:status', { id: roverId, ...status });
|
||||||
|
}
|
||||||
|
|
||||||
|
roverSnapshotEvents.on('frame', ({ id, buffer, ts }) => {
|
||||||
|
const bucket = roverSubscribers.get(id);
|
||||||
|
if (!bucket || !buffer) return;
|
||||||
|
bucket.forEach((socketId) => {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (!socket) return;
|
||||||
|
let lastMap = lastSentBySocket.get(socketId);
|
||||||
|
if (!lastMap) {
|
||||||
|
lastMap = new Map();
|
||||||
|
lastSentBySocket.set(socketId, lastMap);
|
||||||
|
}
|
||||||
|
const lastSent = lastMap.get(id) || 0;
|
||||||
|
const now = ts || Date.now();
|
||||||
|
if (now - lastSent < STREAM_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMap.set(id, now);
|
||||||
|
sendFrame(socket, id, { ts }, buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roverSnapshotEvents.on('status', ({ id, error }) => {
|
||||||
|
const bucket = roverSubscribers.get(id);
|
||||||
|
if (!bucket) return;
|
||||||
|
bucket.forEach((socketId) => {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (!socket) return;
|
||||||
|
sendStatus(socket, id, { error: error || null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('roverSnapshot:subscribe', (payload = {}, cb = () => {}) => {
|
||||||
|
const list = Array.isArray(payload?.ids)
|
||||||
|
? payload.ids.map(String)
|
||||||
|
: payload?.roverId || payload?.id
|
||||||
|
? [String(payload.roverId || payload.id)]
|
||||||
|
: roverManager.getRoster().map((rover) => rover.id);
|
||||||
|
const uniqueIds = Array.from(new Set(list));
|
||||||
|
try {
|
||||||
|
if (!allowSubscribe(socket.id)) {
|
||||||
|
cb({ error: 'Rate limited' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canViewSnapshots(socket)) {
|
||||||
|
throw new Error('Not authorized for rover snapshots');
|
||||||
|
}
|
||||||
|
const rosterIds = new Set(roverManager.getRoster().map((entry) => String(entry.id)));
|
||||||
|
const validIds = uniqueIds.filter((id) => rosterIds.has(String(id)));
|
||||||
|
validIds.forEach((roverId) => addSubscription(socket, roverId));
|
||||||
|
validIds.forEach((roverId) => {
|
||||||
|
const state = getRoverSnapshotState(roverId);
|
||||||
|
if (state?.frame) {
|
||||||
|
sendFrame(socket, roverId, { ts: state.ts }, state.frame);
|
||||||
|
}
|
||||||
|
sendStatus(socket, roverId, {
|
||||||
|
ts: state?.ts || null,
|
||||||
|
error: state?.error || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cb({ ok: true, subscribed: validIds });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Rover snapshot subscribe failed', { socketId: socket.id, err: err.message });
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('roverSnapshot:unsubscribe', (payload = {}) => {
|
||||||
|
const list = Array.isArray(payload?.ids)
|
||||||
|
? payload.ids.map(String)
|
||||||
|
: payload?.roverId || payload?.id
|
||||||
|
? [String(payload.roverId || payload.id)]
|
||||||
|
: [];
|
||||||
|
list.forEach((roverId) => removeSubscription(socket.id, roverId));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
removeAllSubscriptions(socket.id);
|
||||||
|
subscribeBuckets.delete(socket.id);
|
||||||
|
lastSentBySocket.delete(socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,10 +9,17 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
|
|||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||||
|
const { getReplayState, replayEvents } = require('./replayService');
|
||||||
|
const { getReplaySources } = require('./replaySourceService');
|
||||||
|
const { getHealthSnapshot } = require('./healthService');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
const { getCommunityGoal } = require('./communityGoalService');
|
||||||
|
const { subscribe } = require('./eventBus');
|
||||||
|
|
||||||
const discordInvite = loadConfig().discord?.invite || null;
|
const discordInvite = loadConfig().discord?.invite || null;
|
||||||
|
const kofiLink = loadConfig().kofi?.link || null;
|
||||||
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
|
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
|
||||||
|
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
||||||
|
|
||||||
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
|
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
|
||||||
let lastActivitySync = 0;
|
let lastActivitySync = 0;
|
||||||
@@ -45,10 +52,17 @@ function buildSession(socket) {
|
|||||||
turnQueues: getTurnQueues(),
|
turnQueues: getTurnQueues(),
|
||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
|
replay: getReplayState(),
|
||||||
|
replaySources: getReplaySources(),
|
||||||
|
health: getHealthSnapshot(),
|
||||||
|
communityGoal: getCommunityGoal(),
|
||||||
users,
|
users,
|
||||||
discord: {
|
discord: {
|
||||||
invite: discordInvite,
|
invite: discordInvite,
|
||||||
},
|
},
|
||||||
|
kofi: {
|
||||||
|
link: kofiLink,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +170,11 @@ homeAssistantEvents.on('status', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
replayEvents.on('update', () => {
|
||||||
|
logger.info('Replay cooldown updated; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
nicknameEvents.on('change', ({ socketId }) => {
|
nicknameEvents.on('change', ({ socketId }) => {
|
||||||
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
||||||
if (socket) {
|
if (socket) {
|
||||||
@@ -166,6 +185,11 @@ nicknameEvents.on('change', ({ socketId }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
subscribe('communityGoal.updated', () => {
|
||||||
|
logger.info('Community goal updated; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
// sync all sockets 20 seconds
|
// sync all sockets 20 seconds
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
logger.info('Periodic session sync for all clients');
|
logger.info('Periodic session sync for all clients');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const { sendAlert, COLORS } = require('./alertService');
|
const { sendAlert } = require('./alertService');
|
||||||
|
const ALERT_COLOR = '#ff5722';
|
||||||
const { MODES, getMode, modeEvents } = require('./modeManager');
|
const { MODES, getMode, modeEvents } = require('./modeManager');
|
||||||
|
|
||||||
const driverQueues = new Map(); // roverId -> { queue: [], current: socketId, timer: Timeout | null }
|
const driverQueues = new Map(); // roverId -> { queue: [], current: socketId, timer: Timeout | null }
|
||||||
@@ -8,6 +9,7 @@ const activeDrivers = new Map();
|
|||||||
const TURN_DURATION_MS = 60 * 1000;
|
const TURN_DURATION_MS = 60 * 1000;
|
||||||
const IDLE_TIMEOUT_MS = 7 * 1000;
|
const IDLE_TIMEOUT_MS = 7 * 1000;
|
||||||
const MAX_IDLE_SKIPS = 3;
|
const MAX_IDLE_SKIPS = 3;
|
||||||
|
const STALE_REAPER_MS = 5000;
|
||||||
const turnEvents = new EventEmitter();
|
const turnEvents = new EventEmitter();
|
||||||
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
|
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
|
||||||
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
|
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
|
||||||
@@ -39,6 +41,7 @@ function driverRemoved(roverId, socketId) {
|
|||||||
}
|
}
|
||||||
idleDisarmed.delete(roverId);
|
idleDisarmed.delete(roverId);
|
||||||
if (queue.current === socketId) {
|
if (queue.current === socketId) {
|
||||||
|
stopRover(roverId);
|
||||||
advanceTurn(roverId);
|
advanceTurn(roverId);
|
||||||
} else {
|
} else {
|
||||||
scheduleIdleTimer(roverId);
|
scheduleIdleTimer(roverId);
|
||||||
@@ -158,7 +161,7 @@ function handleIdleTimeout(roverId, expectedDriver) {
|
|||||||
stopRover(roverId);
|
stopRover(roverId);
|
||||||
if (skips >= MAX_IDLE_SKIPS) {
|
if (skips >= MAX_IDLE_SKIPS) {
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: COLORS.error,
|
color: ALERT_COLOR,
|
||||||
title: 'Driver removed',
|
title: 'Driver removed',
|
||||||
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
|
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
|
||||||
});
|
});
|
||||||
@@ -166,7 +169,7 @@ function handleIdleTimeout(roverId, expectedDriver) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: COLORS.warn,
|
color: ALERT_COLOR,
|
||||||
title: 'Turn skipped',
|
title: 'Turn skipped',
|
||||||
message: `${expectedDriver} skipped on ${roverId} (idle ${skips}/${MAX_IDLE_SKIPS})`,
|
message: `${expectedDriver} skipped on ${roverId} (idle ${skips}/${MAX_IDLE_SKIPS})`,
|
||||||
});
|
});
|
||||||
@@ -177,6 +180,7 @@ function advanceTurn(roverId) {
|
|||||||
const queue = driverQueues.get(roverId);
|
const queue = driverQueues.get(roverId);
|
||||||
if (!queue) return;
|
if (!queue) return;
|
||||||
if (queue.queue.length === 0) {
|
if (queue.queue.length === 0) {
|
||||||
|
stopRover(roverId);
|
||||||
clearTimeout(queue.timer);
|
clearTimeout(queue.timer);
|
||||||
setActiveDriver(roverId, null);
|
setActiveDriver(roverId, null);
|
||||||
turnDeadlines.delete(roverId);
|
turnDeadlines.delete(roverId);
|
||||||
@@ -205,7 +209,7 @@ function advanceTurn(roverId) {
|
|||||||
queue.current = queue.queue[nextIdx];
|
queue.current = queue.queue[nextIdx];
|
||||||
setActiveDriver(roverId, queue.current);
|
setActiveDriver(roverId, queue.current);
|
||||||
idleDisarmed.set(roverId, false);
|
idleDisarmed.set(roverId, false);
|
||||||
sendAlert({ color: COLORS.info, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` });
|
sendAlert({ color: ALERT_COLOR, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` });
|
||||||
stopRover(roverId);
|
stopRover(roverId);
|
||||||
scheduleNextTurn(roverId);
|
scheduleNextTurn(roverId);
|
||||||
scheduleIdleTimer(roverId);
|
scheduleIdleTimer(roverId);
|
||||||
@@ -265,16 +269,44 @@ function removeDriverCompletely(roverId, socketId) {
|
|||||||
function recordActivity(roverId, socketId) {
|
function recordActivity(roverId, socketId) {
|
||||||
const queue = driverQueues.get(roverId);
|
const queue = driverQueues.get(roverId);
|
||||||
if (!queue || queue.current !== socketId) return;
|
if (!queue || queue.current !== socketId) return;
|
||||||
|
if (idleDisarmed.get(roverId)) return;
|
||||||
idleDisarmed.set(roverId, true);
|
idleDisarmed.set(roverId, true);
|
||||||
|
const hadDeadline = idleDeadlines.has(roverId);
|
||||||
clearTimeout(idleTimers.get(roverId));
|
clearTimeout(idleTimers.get(roverId));
|
||||||
idleDeadlines.delete(roverId);
|
idleDeadlines.delete(roverId);
|
||||||
turnEvents.emit('queue', { roverId, reason: 'activity' });
|
if (hadDeadline) {
|
||||||
|
turnEvents.emit('queue', { roverId, reason: 'activity' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modeEvents.on('change', (mode) => {
|
modeEvents.on('change', (mode) => {
|
||||||
driverQueues.forEach((_, roverId) => syncState(roverId));
|
driverQueues.forEach((_, roverId) => syncState(roverId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function reapStaleDrivers() {
|
||||||
|
const staleIds = new Set();
|
||||||
|
driverQueues.forEach((queue) => {
|
||||||
|
queue.queue.forEach((socketId) => {
|
||||||
|
if (!io.sockets.sockets.has(socketId)) {
|
||||||
|
staleIds.add(socketId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (queue.current && !io.sockets.sockets.has(queue.current)) {
|
||||||
|
staleIds.add(queue.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (staleIds.size === 0) return;
|
||||||
|
driverQueues.forEach((queue, roverId) => {
|
||||||
|
staleIds.forEach((socketId) => {
|
||||||
|
if (queue.current === socketId || queue.queue.includes(socketId)) {
|
||||||
|
driverRemoved(roverId, socketId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(reapStaleDrivers, STALE_REAPER_MS);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
driverAdded,
|
driverAdded,
|
||||||
driverRemoved,
|
driverRemoved,
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ function extractStreamInfo(path) {
|
|||||||
const remaining = segments.slice(start, end);
|
const remaining = segments.slice(start, end);
|
||||||
if (remaining.length === 1) {
|
if (remaining.length === 1) {
|
||||||
const rawId = remaining[0] || '';
|
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 };
|
return { type: 'rover', id: rawId, baseId };
|
||||||
}
|
}
|
||||||
if (remaining.length === 2 && remaining[0] === 'room') {
|
if (remaining.length === 2 && remaining[0] === 'room') {
|
||||||
@@ -74,9 +78,16 @@ app.post('/mediamtx/auth', (req, res) => {
|
|||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
const path = (body.path || '').replace(/^\//, '');
|
const path = (body.path || '').replace(/^\//, '');
|
||||||
const sessionId = body.user;
|
const sessionId = body.user;
|
||||||
|
const action = (body.action || '').toLowerCase();
|
||||||
|
const protocol = (body.protocol || '').toLowerCase();
|
||||||
|
const ip = body.ip || req.ip || '';
|
||||||
const streamInfo = extractStreamInfo(path);
|
const streamInfo = extractStreamInfo(path);
|
||||||
|
|
||||||
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo });
|
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol, ip });
|
||||||
|
|
||||||
|
if (action === 'read' && protocol === 'srt' && streamInfo?.id) {
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
if (!sessionId || !streamInfo?.id) {
|
if (!sessionId || !streamInfo?.id) {
|
||||||
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
|
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { getMode, MODES } = require('./modeManager');
|
|||||||
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||||
const videoSessions = require('./videoSessions');
|
const videoSessions = require('./videoSessions');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
|
const { getRoomCamera } = require('./roomCameraService');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -36,6 +37,17 @@ function buildWhepUrlForSource(source) {
|
|||||||
return `${cleanBase}/${segments.join('/')}/whep`;
|
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) {
|
function passesMode(socket) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
if (mode === MODES.LOCKDOWN) {
|
if (mode === MODES.LOCKDOWN) {
|
||||||
@@ -65,14 +77,21 @@ function canViewRoomCamera(socket) {
|
|||||||
|
|
||||||
function normalizeRequest(payload = {}) {
|
function normalizeRequest(payload = {}) {
|
||||||
if (!payload) return null;
|
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) {
|
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) {
|
if (payload.roverId) {
|
||||||
return { type: 'rover', id: String(payload.roverId) };
|
return { type: 'rover', id: String(payload.roverId), preview, codec };
|
||||||
}
|
}
|
||||||
if (payload.roomCameraId) {
|
if (payload.roomCameraId) {
|
||||||
return { type: 'room', id: String(payload.roomCameraId) };
|
return { type: 'room', id: String(payload.roomCameraId), preview, codec };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -93,16 +112,30 @@ io.on('connection', (socket) => {
|
|||||||
throw new Error('Not authorized for video');
|
throw new Error('Not authorized for video');
|
||||||
}
|
}
|
||||||
} else if (target.type === 'room') {
|
} 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 {
|
} else {
|
||||||
throw new Error('Unsupported video source');
|
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) {
|
if (!url) {
|
||||||
throw new Error('Server video base URL missing');
|
throw new Error('Server video base URL missing');
|
||||||
}
|
}
|
||||||
const sessionId = videoSessions.createSession(socket, target);
|
const sessionId = videoSessions.createSession(socket, requestTarget);
|
||||||
cb({ url, token: sessionId, type: target.type, id: target.id });
|
cb({
|
||||||
|
url,
|
||||||
|
token: sessionId,
|
||||||
|
type: requestTarget.type,
|
||||||
|
id: requestTarget.id,
|
||||||
|
preview: Boolean(target.preview),
|
||||||
|
codec: target.codec || null,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('video request failed: %s', err.message);
|
logger.warn('video request failed: %s', err.message);
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ pnpm-debug.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
# dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
Vendored
+9
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -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>
|
||||||
Vendored
+1
@@ -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 |
@@ -33,6 +33,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-58
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||||
import DrivePanel from './components/DrivePanel.jsx';
|
import ControlSummary, { RoverRosterPanel } from './components/ControlSummary.jsx';
|
||||||
|
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
||||||
import AlertFeed from './components/AlertFeed.jsx';
|
import AlertFeed from './components/AlertFeed.jsx';
|
||||||
import AdminPanel from './components/AdminPanel.jsx';
|
|
||||||
import MobileControls, {
|
import MobileControls, {
|
||||||
MobileLandscapeAuxColumn,
|
MobileLandscapeAuxColumn,
|
||||||
MobileLandscapeControlColumn,
|
MobileLandscapeControlColumn,
|
||||||
@@ -11,17 +11,22 @@ import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from
|
|||||||
import { SettingsProvider } from './settings/index.js';
|
import { SettingsProvider } from './settings/index.js';
|
||||||
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
|
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
|
||||||
import LogPanel from './components/LogPanel.jsx';
|
import LogPanel from './components/LogPanel.jsx';
|
||||||
import AuthPanel from './components/AuthPanel.jsx';
|
|
||||||
import DriverVideoPanel from './components/DriverVideoPanel.jsx';
|
import DriverVideoPanel from './components/DriverVideoPanel.jsx';
|
||||||
import RightPaneTabs from './components/RightPaneTabs.jsx';
|
import RightPaneTabs from './components/RightPaneTabs.jsx';
|
||||||
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
||||||
import SessionSnapshot from './components/SessionSnapshot.jsx';
|
|
||||||
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
|
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
|
||||||
import TurnAlertListener from './components/TurnAlertListener.jsx';
|
import TurnAlertListener from './components/TurnAlertListener.jsx';
|
||||||
import UserListPanel from './components/UserListPanel.jsx';
|
import UserListPanel from './components/UserListPanel.jsx';
|
||||||
import ChatPanel from './components/ChatPanel.jsx';
|
import ChatPanel from './components/ChatPanel.jsx';
|
||||||
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
|
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
|
||||||
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
||||||
|
import { useSettingsNamespace } from './settings/index.js';
|
||||||
|
import HelpOverlay from './components/HelpOverlay.jsx';
|
||||||
|
import HelpPanel from './components/HelpPanel.jsx';
|
||||||
|
import SettingsPanel from './components/SettingsPanel.jsx';
|
||||||
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
|
||||||
|
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||||
|
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
||||||
|
|
||||||
function useLayoutMode() {
|
function useLayoutMode() {
|
||||||
const [mode, setMode] = useState(() => {
|
const [mode, setMode] = useState(() => {
|
||||||
@@ -53,7 +58,7 @@ function useLayoutMode() {
|
|||||||
return mode;
|
return mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopLayout({ layout }) {
|
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-0.5 overflow-hidden">
|
<div className="flex h-full gap-0.5 overflow-hidden">
|
||||||
<div className="flex min-w-0 flex-[1.8] flex-col gap-0.5 overflow-y-auto pr-0.5">
|
<div className="flex min-w-0 flex-[1.8] flex-col gap-0.5 overflow-y-auto pr-0.5">
|
||||||
@@ -69,57 +74,99 @@ function DesktopLayout({ layout }) {
|
|||||||
<LogPanel />
|
<LogPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
<RightPaneTabs layout={layout} />
|
<CommunityGoalBanner layout={layout} />
|
||||||
|
<RightPaneTabs layout={layout} onOpenHelpOverlay={onOpenHelpOverlay} />
|
||||||
{/* <SessionSnapshot /> */}
|
{/* <SessionSnapshot /> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobilePortraitLayout() {
|
function MobileFeatureTabs({
|
||||||
|
layout,
|
||||||
|
onOpenHelpOverlay,
|
||||||
|
roomPanelId,
|
||||||
|
showTelemetry = true,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="panel text-base">
|
||||||
|
<Tabs defaultTab="chat">
|
||||||
|
<TabList>
|
||||||
|
<Tab id="chat">Chat</Tab>
|
||||||
|
<Tab id="roomcontrols">Room Controls</Tab>
|
||||||
|
<Tab id="help">Help</Tab>
|
||||||
|
<Tab id="settings">Settings</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel id="chat">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<ChatPanel />
|
||||||
|
<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 (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<DriverVideoPanel layoutFormat='mobile'/>
|
<DriverVideoPanel layoutFormat="mobile-portrait" />
|
||||||
<MobileControls />
|
<MobileControls />
|
||||||
<ChatPanel />
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<UserListPanel />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||||
<DrivePanel />
|
<RoverRosterPanel />
|
||||||
<TelemetryPanel />
|
</div>
|
||||||
<AuthPanel />
|
{/* <ControlSummary /> */}
|
||||||
<AdminPanel />
|
<MobileFeatureTabs
|
||||||
<RoomCameraPanel panelId="mobile-portrait-room" />
|
layout="mobile-portrait"
|
||||||
<HomeAssistantControls />
|
onOpenHelpOverlay={onOpenHelpOverlay}
|
||||||
<LogPanel />
|
roomPanelId="mobile-portrait-room"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileLandscapeLayout() {
|
function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
||||||
<MobileLandscapeAuxColumn />
|
<MobileLandscapeAuxColumn />
|
||||||
<div>
|
<div>
|
||||||
<DriverVideoPanel layoutFormat='mobile'/>
|
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||||
<TelemetryPanel />
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
|
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||||
|
<RoverRosterPanel />
|
||||||
|
</div>
|
||||||
|
{/* <TelemetryPanel /> */}
|
||||||
</div>
|
</div>
|
||||||
<MobileLandscapeControlColumn />
|
<MobileLandscapeControlColumn />
|
||||||
</section>
|
</section>
|
||||||
<div className="flex flex-col gap-0.5 pb-0.5">
|
<div className="flex flex-col gap-0.5 pb-0.5">
|
||||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
|
<MobileFeatureTabs
|
||||||
<div className="flex flex-col gap-0.5">
|
layout="mobile-landscape"
|
||||||
{/* <AuthPanel /> */}
|
onOpenHelpOverlay={onOpenHelpOverlay}
|
||||||
{/* <AdminPanel /> */}
|
roomPanelId="mobile-landscape-room"
|
||||||
<UserListPanel />
|
showTelemetry={false}
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{/* <LogPanel /> */}
|
|
||||||
<ChatPanel />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<RoomCameraPanel panelId="mobile-landscape-room" />
|
|
||||||
<HomeAssistantControls />
|
|
||||||
{/* <DrivePanel /> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -128,36 +175,88 @@ function MobileLandscapeLayout() {
|
|||||||
function App() {
|
function App() {
|
||||||
const layout = useLayoutMode();
|
const layout = useLayoutMode();
|
||||||
const isDesktop = layout === 'desktop';
|
const isDesktop = layout === 'desktop';
|
||||||
const { visible: fullscreenVisible, mode: fullscreenMode, enterFullscreen, dismiss } = useFullscreenPrompt(layout);
|
const fullscreen = useFullscreenPrompt(layout);
|
||||||
const renderedLayout =
|
|
||||||
isDesktop
|
|
||||||
? <DesktopLayout layout={layout} />
|
|
||||||
: layout === 'mobile-landscape'
|
|
||||||
? <MobileLandscapeLayout />
|
|
||||||
: <MobilePortraitLayout />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
|
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ControlSystemProvider>
|
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
|
||||||
<KeyboardInputManager />
|
|
||||||
<GamepadInputManager />
|
|
||||||
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
|
|
||||||
{renderedLayout}
|
|
||||||
</main>
|
|
||||||
<AlertFeed />
|
|
||||||
<TurnAlertListener />
|
|
||||||
<ModeGateOverlay />
|
|
||||||
<FullscreenPrompt
|
|
||||||
visible={fullscreenVisible}
|
|
||||||
mode={fullscreenMode}
|
|
||||||
onEnterFullscreen={enterFullscreen}
|
|
||||||
onDismiss={dismiss}
|
|
||||||
/>
|
|
||||||
</ControlSystemProvider>
|
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||||
|
useDefaultNickname();
|
||||||
|
const {
|
||||||
|
visible: fullscreenVisible,
|
||||||
|
mode: fullscreenMode,
|
||||||
|
enterFullscreen,
|
||||||
|
dismiss,
|
||||||
|
} = fullscreen;
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: helpSettings,
|
||||||
|
status: helpStatus,
|
||||||
|
save: saveHelpSettings,
|
||||||
|
} = useSettingsNamespace('help', { showOnLoad: true });
|
||||||
|
const [helpVisible, setHelpVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (helpStatus === 'ready') {
|
||||||
|
setHelpVisible(helpSettings?.showOnLoad !== false);
|
||||||
|
}
|
||||||
|
}, [helpStatus, helpSettings?.showOnLoad]);
|
||||||
|
|
||||||
|
const openHelp = useCallback(() => setHelpVisible(true), []);
|
||||||
|
const closeHelp = useCallback(() => setHelpVisible(false), []);
|
||||||
|
const setShowOnLoad = useCallback(
|
||||||
|
(enabled) => {
|
||||||
|
const next = Boolean(enabled);
|
||||||
|
saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
||||||
|
if (!next) {
|
||||||
|
setHelpVisible(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[saveHelpSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderedLayout = useMemo(
|
||||||
|
() =>
|
||||||
|
isDesktop
|
||||||
|
? <DesktopLayout layout={layout} onOpenHelpOverlay={openHelp} />
|
||||||
|
: layout === 'mobile-landscape'
|
||||||
|
? <MobileLandscapeLayout onOpenHelpOverlay={openHelp} />
|
||||||
|
: <MobilePortraitLayout onOpenHelpOverlay={openHelp} />,
|
||||||
|
[isDesktop, layout, openHelp],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlSystemProvider>
|
||||||
|
<KeyboardInputManager />
|
||||||
|
<GamepadInputManager />
|
||||||
|
<main className={`flex w-full flex-col gap-0.5 text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
|
||||||
|
{!isDesktop ? <CommunityGoalBanner layout={layout} /> : null}
|
||||||
|
{renderedLayout}
|
||||||
|
</main>
|
||||||
|
<AlertFeed />
|
||||||
|
<TurnAlertListener />
|
||||||
|
<ModeGateOverlay />
|
||||||
|
<HelpOverlay
|
||||||
|
visible={helpVisible}
|
||||||
|
layout={layout}
|
||||||
|
onClose={closeHelp}
|
||||||
|
showOnLoad={helpSettings?.showOnLoad !== false}
|
||||||
|
onToggleShowOnLoad={setShowOnLoad}
|
||||||
|
/>
|
||||||
|
<FullscreenPrompt
|
||||||
|
visible={fullscreenVisible}
|
||||||
|
mode={fullscreenMode}
|
||||||
|
onEnterFullscreen={enterFullscreen}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
/>
|
||||||
|
</ControlSystemProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import RoverRoster from './RoverRoster.jsx';
|
import RoverRoster from './RoverRoster.jsx';
|
||||||
|
|
||||||
@@ -10,9 +10,13 @@ const MODES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminPanel() {
|
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 roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
|
const health = session?.health || null;
|
||||||
|
const currentGoal = session?.communityGoal?.text || '';
|
||||||
|
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
|
||||||
|
const [goalDraft, setGoalDraft] = useState(currentGoal);
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session?.role === 'admin' ||
|
session?.role === 'admin' ||
|
||||||
@@ -47,6 +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 lockMap = useMemo(() => {
|
||||||
const map = {};
|
const map = {};
|
||||||
roster.forEach((rover) => {
|
roster.forEach((rover) => {
|
||||||
@@ -69,6 +93,29 @@ export default function AdminPanel() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Community goal</span>
|
||||||
|
{goalUpdatedAt ? (
|
||||||
|
<span>Updated {new Date(goalUpdatedAt).toLocaleString()}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={goalDraft}
|
||||||
|
onChange={(event) => setGoalDraft(event.target.value)}
|
||||||
|
placeholder="Set a community goal"
|
||||||
|
className="field-input text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-0.5 text-xs">
|
||||||
|
<button type="button" onClick={handleGoalSave} className="button-dark">
|
||||||
|
Set goal
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleGoalClear} className="button-danger">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RoverRoster
|
<RoverRoster
|
||||||
roster={roster}
|
roster={roster}
|
||||||
@@ -87,6 +134,69 @@ export default function AdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<ReplaySnapshotHealth health={health} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReplaySnapshotHealth({ health }) {
|
||||||
|
if (!health) return null;
|
||||||
|
const replay = health.replay || { sources: [], readyCount: 0, totalCount: 0 };
|
||||||
|
const snapshots = health.snapshots || { rovers: [], rooms: [] };
|
||||||
|
const replaySummary = `${replay.readyCount}/${replay.totalCount} sources ready`;
|
||||||
|
const roverStale = snapshots.rovers.filter((entry) => entry.stale).length;
|
||||||
|
const roomStale = snapshots.rooms.filter((entry) => entry.stale).length;
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">Health</div>
|
||||||
|
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Replay segments</span>
|
||||||
|
<span className="text-slate-400">{replaySummary}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Rover snapshots</span>
|
||||||
|
<span className={roverStale ? 'text-amber-300' : 'text-emerald-300'}>
|
||||||
|
{snapshots.rovers.length - roverStale}/{snapshots.rovers.length} ok
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Room cameras</span>
|
||||||
|
<span className={roomStale ? 'text-amber-300' : 'text-emerald-300'}>
|
||||||
|
{snapshots.rooms.length - roomStale}/{snapshots.rooms.length} ok
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||||
|
|
||||||
const LIFETIME_MS = 5000;
|
const LIFETIME_MS = 3000;
|
||||||
|
const DEFAULT_COLOR = '#2196f3';
|
||||||
|
|
||||||
function buildKey(alert) {
|
function buildKey(alert) {
|
||||||
if (alert.id) return alert.id;
|
if (alert.id) return alert.id;
|
||||||
@@ -12,7 +14,7 @@ function buildKey(alert) {
|
|||||||
export default function AlertFeed() {
|
export default function AlertFeed() {
|
||||||
const { alerts } = useSession();
|
const { alerts } = useSession();
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const latest = useMemo(() => alerts.slice(-5).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
const latest = useMemo(() => alerts.slice(-3).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latest.length) return undefined;
|
if (!latest.length) return undefined;
|
||||||
@@ -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 }) {
|
function AlertToast({ alert }) {
|
||||||
const colorClasses =
|
if (alert.kind === 'chat' && alert.payload) {
|
||||||
alert.color === 'error'
|
return <ChatMessageRow message={alert.payload} />;
|
||||||
? 'bg-red-500/30 text-red-100'
|
}
|
||||||
: alert.color === 'warn'
|
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
|
||||||
? 'bg-amber-500/30 text-amber-100'
|
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
|
||||||
: 'bg-emerald-500/30 text-emerald-100';
|
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
|
||||||
return (
|
return (
|
||||||
<div className={`pointer-events-auto px-0.5 py-0.5 text-[0.85rem] ${colorClasses}`}>
|
<div
|
||||||
<p className="text-xs text-slate-200">{alert.title || 'Alert'}</p>
|
className="pointer-events-auto max-w-[80vw] rounded-full border px-2 py-0.5 text-[0.7rem] leading-tight text-slate-100 shadow-sm shadow-black/30"
|
||||||
<p className="text-sm text-white">{alert.message}</p>
|
style={{ backgroundColor, border: `1px solid ${borderColor}` }}
|
||||||
|
>
|
||||||
|
<p className="truncate text-slate-100">
|
||||||
|
<span className="text-slate-300">{alert.title || 'Alert'}</span>
|
||||||
|
<span className="text-slate-500"> · </span>
|
||||||
|
<span className="text-white">{alert.message}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useChat } from '../context/ChatContext.jsx';
|
import { useChat } from '../context/ChatContext.jsx';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
function roleColors(role, fromDiscord) {
|
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||||
if (fromDiscord) return 'text-indigo-200';
|
|
||||||
switch (role) {
|
|
||||||
case 'admin':
|
|
||||||
case 'lockdown':
|
|
||||||
case 'lockdown-admin':
|
|
||||||
return 'text-amber-300';
|
|
||||||
case 'spectator':
|
|
||||||
return 'text-slate-400';
|
|
||||||
default:
|
|
||||||
return 'text-sky-300';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts) {
|
|
||||||
const date = new Date(ts);
|
|
||||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayName(message) {
|
|
||||||
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
||||||
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
||||||
@@ -31,12 +10,16 @@ const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
|||||||
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
|
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
||||||
|
const {
|
||||||
|
value: ttsSettings,
|
||||||
|
save: saveTtsSettings,
|
||||||
|
} = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [speak, setSpeak] = useState(false);
|
const [speak, setSpeak] = useState(false);
|
||||||
const [engine, setEngine] = useState('flite');
|
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
|
||||||
const [voice, setVoice] = useState('rms');
|
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
|
||||||
const [pitch, setPitch] = useState(50);
|
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
|
||||||
const canChat = session?.role !== 'spectator';
|
const canChat = session?.role !== 'spectator';
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null);
|
||||||
const currentRoverId = session?.assignment?.roverId || null;
|
const currentRoverId = session?.assignment?.roverId || null;
|
||||||
@@ -54,6 +37,15 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
}, [sorted]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (ttsSupported) {
|
if (ttsSupported) {
|
||||||
setSpeak(true);
|
setSpeak(true);
|
||||||
@@ -97,52 +89,29 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||||
) : (
|
) : (
|
||||||
sorted.map((msg) => {
|
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
|
||||||
const isAdmin =
|
|
||||||
msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={msg.id}
|
|
||||||
className={`surface-muted text-sm flex items-start gap-1 ${
|
|
||||||
isAdmin
|
|
||||||
? 'border border-amber-400/30'
|
|
||||||
: msg.fromDiscord
|
|
||||||
? 'border border-indigo-400/30 bg-indigo-900/20'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-[0.75rem] text-slate-400">{formatTime(msg.ts)}</span>
|
|
||||||
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role, msg.fromDiscord)}`}>
|
|
||||||
{displayName(msg)}
|
|
||||||
</span>
|
|
||||||
{msg.fromDiscord && (
|
|
||||||
<span className="rounded bg-indigo-500/30 px-1 text-[0.7rem] text-indigo-100">
|
|
||||||
Discord
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{msg.roverId && (
|
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {msg.roverId}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{msg.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!hideInput && (
|
{!hideInput && (
|
||||||
<form className="flex gap-0.5" onSubmit={handleSend}>
|
<form className="flex flex-wrap items-start gap-0.5" onSubmit={handleSend}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1"
|
className="field-input flex-1 min-w-[10rem]"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onFocus={onInputFocus}
|
onFocus={onInputFocus}
|
||||||
onBlur={onInputBlur}
|
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'}
|
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
/>
|
/>
|
||||||
{ttsSupported && (
|
{ttsSupported && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
|
||||||
<label className="flex items-center gap-0.25 text-xs text-slate-300">
|
<label className="flex items-center gap-0.25 text-xs text-slate-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -154,7 +123,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={engine}
|
value={engine}
|
||||||
onChange={(e) => setEngine(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setEngine(next);
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), engine: next }));
|
||||||
|
}}
|
||||||
className="field-input text-xs"
|
className="field-input text-xs"
|
||||||
>
|
>
|
||||||
<option value="flite">flite</option>
|
<option value="flite">flite</option>
|
||||||
@@ -163,7 +136,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
{engine === 'flite' ? (
|
{engine === 'flite' ? (
|
||||||
<select
|
<select
|
||||||
value={voice}
|
value={voice}
|
||||||
onChange={(e) => setVoice(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setVoice(next);
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
|
||||||
|
}}
|
||||||
className="field-input text-xs"
|
className="field-input text-xs"
|
||||||
>
|
>
|
||||||
{FLITE_VOICES.map((v) => (
|
{FLITE_VOICES.map((v) => (
|
||||||
@@ -175,7 +152,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={pitch}
|
value={pitch}
|
||||||
onChange={(e) => setPitch(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
const next = Number(e.target.value);
|
||||||
|
setPitch(next);
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
|
||||||
|
}}
|
||||||
className="field-input text-xs"
|
className="field-input text-xs"
|
||||||
>
|
>
|
||||||
{ESPEAK_PITCHES.map((p) => (
|
{ESPEAK_PITCHES.map((p) => (
|
||||||
@@ -187,7 +168,11 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canChat || sending}
|
||||||
|
className="button-dark disabled:opacity-50 self-start"
|
||||||
|
>
|
||||||
{sending ? '...' : 'Send'}
|
{sending ? '...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}°`;
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
|||||||
<a
|
<a
|
||||||
href={discordInvite}
|
href={discordInvite}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"npm
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center px-3 py-2 text-white rainbow-animate-bg transition justify-center"
|
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center"
|
||||||
// animated rainbow backgound
|
// animated rainbow backgound
|
||||||
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,16 +35,16 @@ export default function DrivePanel() {
|
|||||||
</div> */}
|
</div> */}
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ActionCard
|
<ActionCard
|
||||||
title="Start Driving"
|
title="Startfdsafsdfakjfdhlkfshlkjss Driving"
|
||||||
description="Press to enable driving mode, then start moving."
|
description="Press to enable driving mode, then start moving."
|
||||||
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]}
|
statuses={[{ label: drivingMode ? 'Ready!' : 'Press to enter driving mode!', active: drivingMode }]}
|
||||||
tone="emerald"
|
tone="emerald"
|
||||||
onClick={handleStartDrive}
|
onClick={handleStartDrive}
|
||||||
disabled={!roverId}
|
disabled={!roverId}
|
||||||
/>
|
/>
|
||||||
<ActionCard
|
<ActionCard
|
||||||
title="Dock and Charge"
|
title="Dock and Charge"
|
||||||
description="Line the rover up about a foot from the dock, then trigger an automatic docking attempt."
|
description="Line the rover up about a foot from the dock, then press to trigger a docking attempt."
|
||||||
statuses={[
|
statuses={[
|
||||||
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
|
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
|
||||||
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
|
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
|
||||||
@@ -55,6 +55,7 @@ export default function DrivePanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* <DiscordInviteButton /> */}
|
{/* <DiscordInviteButton /> */}
|
||||||
|
{/* kjfhdljhdsaflkjf */}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,94 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||||
|
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
||||||
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import VideoTile from './VideoTile.jsx';
|
import VideoTile from './VideoTile.jsx';
|
||||||
|
import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
|
||||||
|
|
||||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||||
const { session } = useSession();
|
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 roverId = session?.assignment?.roverId;
|
||||||
const rosterEntry =
|
const rosterEntry =
|
||||||
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
|
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||||
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
|
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
|
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` }] : []),
|
...(hasAudio ? [{ type: 'rover', id: `${roverId}-audio`, key: `${roverId}-audio` }] : []),
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
const sources = useVideoRequests(entries);
|
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 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 frame = useTelemetryFrame(roverId);
|
||||||
const batteryRecord =
|
const batteryRecord =
|
||||||
roverId && session?.roster
|
roverId && session?.roster
|
||||||
@@ -27,16 +98,58 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
|
|
||||||
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
|
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 (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
{roverId ? (
|
{roverId ? (
|
||||||
<VideoTile
|
<VideoTile
|
||||||
sessionInfo={info}
|
sessionInfo={shouldShowVideo ? info : previewSession?.url ? previewSession : null}
|
||||||
|
videoMode={shouldShowVideo ? 'whep' : previewSession?.url ? 'whep' : 'snapshot'}
|
||||||
|
snapshotFeed={snapshotFeed}
|
||||||
audioSessionInfo={audioInfo}
|
audioSessionInfo={audioInfo}
|
||||||
label={roverLabel}
|
label={roverLabel}
|
||||||
telemetryFrame={frame}
|
telemetryFrame={frame}
|
||||||
batteryConfig={batteryConfig}
|
batteryConfig={batteryConfig}
|
||||||
layoutFormat={layoutFormat}
|
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">
|
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,22 @@
|
|||||||
export default function HelpPanel({ layout }) {
|
import { useControlSystem } from '../controls/index.js';
|
||||||
const presets = {
|
import HelpContentView from './HelpContentView.jsx';
|
||||||
desktop: [
|
|
||||||
'Use WASD + Shift to drive. Video must stay focused.',
|
export default function HelpPanel({ layout, onOpenOverlay }) {
|
||||||
'Macros on the left prep the rover before moving.',
|
const { state } = useControlSystem();
|
||||||
'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;
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section space-y-0.5 text-sm">
|
||||||
<p className="text-sm text-slate-400">Help</p>
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<ul className="space-y-0.5 text-sm">
|
<span>Help</span>
|
||||||
{tips.map((tip) => (
|
<button
|
||||||
<li key={tip} className="surface text-slate-200">
|
type="button"
|
||||||
{tip}
|
onClick={onOpenOverlay}
|
||||||
</li>
|
className="button-dark px-1 py-0.25 text-[0.75rem]"
|
||||||
))}
|
>
|
||||||
</ul>
|
Open full help
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<HelpContentView layout={layout} keymap={state?.keymap || {}} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import { useControlSystem } from '../controls/index.js';
|
||||||
|
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||||
|
|
||||||
function StatusBadge({ label, tone = 'muted' }) {
|
function StatusBadge({ label, tone = 'muted' }) {
|
||||||
const styles =
|
const styles =
|
||||||
@@ -32,12 +34,12 @@ function EntityRow({ entity, connected, onToggle }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(entity.id)}
|
onClick={() => onToggle(entity.id)}
|
||||||
disabled={disableToggle}
|
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="min-w-0">
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-sm">
|
||||||
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
|
<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>
|
||||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||||
<StatusBadge label={statusLabel} tone={statusTone} />
|
<StatusBadge label={statusLabel} tone={statusTone} />
|
||||||
@@ -50,9 +52,14 @@ function EntityRow({ entity, connected, onToggle }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HomeAssistantControls() {
|
export default function HomeAssistantControls() {
|
||||||
|
const {
|
||||||
|
state: { keymap },
|
||||||
|
} = useControlSystem();
|
||||||
const { session, homeAssistantToggle } = useSession();
|
const { session, homeAssistantToggle } = useSession();
|
||||||
const ha = session?.homeAssistant;
|
const ha = session?.homeAssistant;
|
||||||
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
|
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
|
||||||
|
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
|
||||||
|
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
|
||||||
|
|
||||||
if (!ha?.enabled) {
|
if (!ha?.enabled) {
|
||||||
return (
|
return (
|
||||||
@@ -80,10 +87,20 @@ export default function HomeAssistantControls() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<p>Light Controls</p>
|
<p>Light Controls</p>
|
||||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
<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>
|
</div>
|
||||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||||
</header>
|
</header>
|
||||||
<div className="space-y-0.5">
|
<div className="flex flex-wrap gap-0.5">
|
||||||
{entities.map((entity) => (
|
{entities.map((entity) => (
|
||||||
<EntityRow
|
<EntityRow
|
||||||
key={entity.id}
|
key={entity.id}
|
||||||
@@ -96,3 +113,8 @@ export default function HomeAssistantControls() {
|
|||||||
</section>
|
</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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useMemo, useState, useEffect, useCallback } from 'react';
|
|||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import { DEFAULT_KEYMAP } from '../controls/constants.js';
|
import { DEFAULT_KEYMAP } from '../controls/constants.js';
|
||||||
import { canonicalizeKeyInput, formatKeyLabel } from '../controls/keymapUtils.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 = [
|
const KEY_ACTIONS = [
|
||||||
{ id: 'driveForward', label: 'Drive Forward', group: 'Driving' },
|
{ id: 'driveForward', label: 'Drive Forward', group: 'Driving' },
|
||||||
@@ -19,9 +21,14 @@ const KEY_ACTIONS = [
|
|||||||
{ id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' },
|
{ id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' },
|
||||||
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
|
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
|
||||||
{ id: 'cameraDown', label: 'Camera Down', 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: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
|
||||||
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
||||||
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
|
{ 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) {
|
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) {
|
function useKeyCapture(onCapture) {
|
||||||
const [active, setActive] = useState(null);
|
const [active, setActive] = useState(null);
|
||||||
|
|
||||||
@@ -56,11 +100,59 @@ function useKeyCapture(onCapture) {
|
|||||||
return { active, startCapture: setActive, cancel: () => setActive(null) };
|
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() {
|
export default function KeymapSettings() {
|
||||||
const {
|
const {
|
||||||
state: { keymap },
|
state: { keymap },
|
||||||
actions: { updateKeyBinding, resetKeyBindings },
|
actions: { updateKeyBinding, resetKeyBindings },
|
||||||
} = useControlSystem();
|
} = 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 grouped = useMemo(() => groupActions(KEY_ACTIONS), []);
|
||||||
const { active, startCapture, cancel } = useKeyCapture((actionId, value) => {
|
const { active, startCapture, cancel } = useKeyCapture((actionId, value) => {
|
||||||
updateKeyBinding(actionId, value);
|
updateKeyBinding(actionId, value);
|
||||||
@@ -71,6 +163,38 @@ export default function KeymapSettings() {
|
|||||||
[keymap],
|
[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 (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-sm">
|
<section className="panel-section space-y-0.5 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -82,6 +206,41 @@ export default function KeymapSettings() {
|
|||||||
Reset defaults
|
Reset defaults
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="space-y-0.5">
|
||||||
{Object.entries(grouped).map(([group, actions]) => (
|
{Object.entries(grouped).map(([group, actions]) => (
|
||||||
<div key={group} className="space-y-0.5 surface">
|
<div key={group} className="space-y-0.5 surface">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import { clampUnit } from '../controls/controlMath.js';
|
import { clampUnit } from '../controls/controlMath.js';
|
||||||
import DriveModeToggle from './controls/DriveModeToggle.jsx';
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
|
|
||||||
const SOURCE = 'mobile-joystick';
|
const SOURCE = 'mobile-joystick';
|
||||||
const JOYSTICK_RADIUS = 80;
|
const JOYSTICK_RADIUS = 80;
|
||||||
@@ -133,11 +133,15 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
|||||||
function MobileJoystickPanel({ layout }) {
|
function MobileJoystickPanel({ layout }) {
|
||||||
const {
|
const {
|
||||||
state: { roverId, camera },
|
state: { roverId, camera },
|
||||||
actions: { setDriveVector, registerInputState, stopAllMotion, setServoAngle },
|
pipeline,
|
||||||
|
actions: { setDriveVector, registerInputState, setServoAngle, toggleNightVision },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
|
const driveDockState = useDriveDockState(roverId);
|
||||||
|
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||||
const disabled = !roverId;
|
const disabled = !roverId;
|
||||||
const cameraConfig = camera?.config;
|
const cameraConfig = camera?.config;
|
||||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||||
|
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||||
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
||||||
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
|
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
|
||||||
const cameraValue =
|
const cameraValue =
|
||||||
@@ -194,37 +198,51 @@ function MobileJoystickPanel({ layout }) {
|
|||||||
setServoAngle(next);
|
setServoAngle(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||||
<div className="flex flex-col gap-0.5 text-slate-100">
|
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||||
|
|
||||||
<DriveModeToggle size="compact" />
|
return (
|
||||||
{cameraEnabled && (
|
<div className={containerClass}>
|
||||||
<div className="bg-zinc-950 p-0.5 text-xs">
|
<DriveDockAction layout="mobile" expand={dockedNotDriving} driveDockState={driveDockState} />
|
||||||
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
|
{!dockedNotDriving ? (
|
||||||
<span>Camera Tilt</span>
|
<>
|
||||||
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
|
{nightVisionAvailable && (
|
||||||
</div>
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="range"
|
onClick={() => toggleNightVision()}
|
||||||
min={cameraMin}
|
disabled={disabled}
|
||||||
max={cameraMax}
|
className="bg-amber-600 px-0.5 py-1 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-40"
|
||||||
step={0.5}
|
>
|
||||||
value={cameraValue}
|
Toggle Night Vision
|
||||||
onChange={handleCameraSlider}
|
</button>
|
||||||
className="mt-0.5 w-full accent-cyan-400"
|
)}
|
||||||
|
{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>
|
</>
|
||||||
)}
|
) : null}
|
||||||
<FloatingJoystick
|
{/* Panic stop button can be re-enabled here if needed */}
|
||||||
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> */}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -266,7 +284,7 @@ function AuxMotorPanel({ orientation }) {
|
|||||||
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
||||||
|
|
||||||
return (
|
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>
|
<p className="text-xs text-slate-400">Aux controls</p>
|
||||||
<div className={`grid ${gridCols} gap-0.5`}>
|
<div className={`grid ${gridCols} gap-0.5`}>
|
||||||
{AUX_BUTTONS.map((button) => (
|
{AUX_BUTTONS.map((button) => (
|
||||||
@@ -311,7 +329,7 @@ export function MobileLandscapeControlColumn() {
|
|||||||
export default function MobilePortraitControls() {
|
export default function MobilePortraitControls() {
|
||||||
return (
|
return (
|
||||||
<section className="panel">
|
<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" />
|
<AuxMotorPanel orientation="portrait" />
|
||||||
<MobileJoystickPanel layout="portrait" />
|
<MobileJoystickPanel layout="portrait" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function NicknameForm({ compact = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex gap-0.5" onSubmit={handleSave}>
|
<form className="flex w-full gap-0.5" onSubmit={handleSave}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1"
|
className="field-input flex-1"
|
||||||
value={nicknameInput}
|
value={nicknameInput}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +1,35 @@
|
|||||||
import TelemetryPanel from './TelemetryPanel.jsx';
|
import TelemetryPanel from './TelemetryPanel.jsx';
|
||||||
import DrivePanel from './DrivePanel.jsx';
|
import ControlSummary from './ControlSummary.jsx';
|
||||||
import CameraServoPanel from './CameraServoPanel.jsx';
|
|
||||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||||
import SettingsPanel from './SettingsPanel.jsx';
|
import SettingsPanel from './SettingsPanel.jsx';
|
||||||
import HelpPanel from './HelpPanel.jsx';
|
import HelpPanel from './HelpPanel.jsx';
|
||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||||
|
|
||||||
export default function RightPaneTabs({ layout }) {
|
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||||
return (
|
return (
|
||||||
<section className="panel text-base">
|
<section className="panel text-base">
|
||||||
<Tabs defaultTab="telemetry">
|
<Tabs defaultTab="telemetry">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="telemetry">Rover Controls</Tab>
|
<Tab id="telemetry">Controls</Tab>
|
||||||
<Tab id="room">Room Controls</Tab>
|
|
||||||
<Tab id="settings">Settings</Tab>
|
|
||||||
<Tab id="help">Help</Tab>
|
<Tab id="help">Help</Tab>
|
||||||
|
<Tab id="settings">Settings</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel id="telemetry">
|
<TabPanel id="telemetry">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<DrivePanel />
|
<ControlSummary />
|
||||||
|
<HomeAssistantControls />
|
||||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<CameraServoPanel />
|
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="room">
|
<TabPanel id="help">
|
||||||
<div className="space-y-0.5">
|
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
|
||||||
<RoomCameraPanel defaultOrientation="vertical" panelId="rightpane-room" />
|
|
||||||
<HomeAssistantControls />
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="settings">
|
<TabPanel id="settings">
|
||||||
<SettingsPanel />
|
<SettingsPanel />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="help">
|
|
||||||
<HelpPanel layout={layout} />
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user