mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
switched rover <-> server streams to rtsptcp, and moved mediamtx to be a server owned and configured child process!
This commit is contained in:
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The
|
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The forward URL is
|
||||||
# forward URL is data produced by roverd, and treating it as shell code would
|
# data produced by roverd and must never be interpreted as executable shell code.
|
||||||
# break on normal SRT query-string characters such as '&'.
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
local content=""
|
local content=""
|
||||||
|
|
||||||
@@ -95,6 +94,9 @@ run_pipeline() {
|
|||||||
-flags low_delay
|
-flags low_delay
|
||||||
-analyzeduration 200k
|
-analyzeduration 200k
|
||||||
-probesize 32k
|
-probesize 32k
|
||||||
|
# The forwarded-audio URL is RTSP. Pinning TCP avoids ffmpeg negotiating the
|
||||||
|
# separate unreliable RTP/UDP transport that the server intentionally disables.
|
||||||
|
-rtsp_transport tcp
|
||||||
-i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
|
-i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
|
||||||
-vn
|
-vn
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs
|
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. URLs are data;
|
||||||
# contain characters such as '&' and '#!', so sourcing this file would treat a
|
# sourcing this file would unnecessarily treat server-provided values as shell code.
|
||||||
# data file as code and can split a valid URL into shell control operators.
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
local content=""
|
local content=""
|
||||||
|
|
||||||
@@ -123,13 +122,14 @@ run_pipeline() {
|
|||||||
-frame_duration 20
|
-frame_duration 20
|
||||||
-compression_level 0
|
-compression_level 0
|
||||||
|
|
||||||
# Mirror the video publisher's MPEG-TS low-latency settings. Without
|
# RTSP carries the existing Opus stream directly, avoiding MediaMTX's costly
|
||||||
# these, ffmpeg is allowed to hold packets for mux timing, which is
|
# MPEG-TS demux without changing microphone capture or encoding quality. TCP is
|
||||||
# exactly the wrong tradeoff for live rover feedback.
|
# required for the same reliable local-network behavior as the video publisher.
|
||||||
-flush_packets 1
|
-flush_packets 1
|
||||||
-muxdelay 0
|
-muxdelay 0
|
||||||
-muxpreload 0
|
-muxpreload 0
|
||||||
-f mpegts
|
-f rtsp
|
||||||
|
-rtsp_transport tcp
|
||||||
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
|
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load roverd's generated media.env as data instead of sourcing it as shell.
|
# Load roverd's generated media.env as data instead of sourcing it as shell. URLs are
|
||||||
# The SRT publish URL contains normal query-string characters like '&' and '#!',
|
# configuration data, so evaluating the file would be both fragile and unnecessary.
|
||||||
# so evaluating the file would be both fragile and unnecessary.
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
local content=""
|
local content=""
|
||||||
|
|
||||||
@@ -103,6 +102,8 @@ if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
run_pipeline() {
|
run_pipeline() {
|
||||||
|
# Keep laptop rovers on the same transport contract as Pi camera rovers. This changes
|
||||||
|
# only the encoded stream's carrier; V4L2 capture and H264 encoding remain untouched.
|
||||||
"${FFMPEG_BIN_PATH}" \
|
"${FFMPEG_BIN_PATH}" \
|
||||||
-hide_banner \
|
-hide_banner \
|
||||||
-loglevel warning \
|
-loglevel warning \
|
||||||
@@ -130,7 +131,8 @@ run_pipeline() {
|
|||||||
-flush_packets 1 \
|
-flush_packets 1 \
|
||||||
-muxdelay 0 \
|
-muxdelay 0 \
|
||||||
-muxpreload 0 \
|
-muxpreload 0 \
|
||||||
-f mpegts \
|
-f rtsp \
|
||||||
|
-rtsp_transport tcp \
|
||||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# These publishers contain hardware-facing infinite retry loops, so executing them in a unit
|
||||||
|
# test would require unsafe process-group traps and fake camera/ALSA devices. Pin the small
|
||||||
|
# transport boundary directly instead: every publisher must request RTSP/TCP and none may
|
||||||
|
# reintroduce the high-latency MPEG-TS muxer.
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
|
||||||
|
assert_rtsp_tcp() {
|
||||||
|
local file="$1"
|
||||||
|
if ! grep -q -- '-f rtsp' "$file"; then
|
||||||
|
echo "Missing RTSP muxer in $file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q -- '-rtsp_transport tcp' "$file"; then
|
||||||
|
echo "Missing RTSP/TCP pin in $file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q -- '-f mpegts' "$file"; then
|
||||||
|
echo "Unexpected MPEG-TS muxer in $file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_rtsp_tcp "$SCRIPT_DIR/video-publisher.sh"
|
||||||
|
assert_rtsp_tcp "$SCRIPT_DIR/debian-laptop-video-publisher.sh"
|
||||||
|
assert_rtsp_tcp "$SCRIPT_DIR/audio-only-publisher.sh"
|
||||||
|
|
||||||
|
# The speaker path reads rather than publishes, so it has no output muxer. It must still pin
|
||||||
|
# RTSP/TCP before its input URL to match the server's TCP-only listener.
|
||||||
|
if ! grep -q -- '-rtsp_transport tcp' "$SCRIPT_DIR/audio-forward-listener.sh"; then
|
||||||
|
echo "Missing RTSP/TCP input pin in audio-forward-listener.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Media publisher transport checks passed"
|
||||||
@@ -110,6 +110,10 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
run_pipeline() {
|
run_pipeline() {
|
||||||
|
# MPEG-TS added most of the former rover-to-browser latency inside MediaMTX's
|
||||||
|
# demuxer. RTSP carries the same encoded H264 without changing the camera or codec.
|
||||||
|
# TCP is explicit because plain RTSP/RTP over UDP has no retransmission and proved
|
||||||
|
# unreliable even though MediaMTX still reported the incomplete stream as ready.
|
||||||
"${LIBCAMERA_BIN_PATH}" \
|
"${LIBCAMERA_BIN_PATH}" \
|
||||||
--inline \
|
--inline \
|
||||||
--timeout 0 \
|
--timeout 0 \
|
||||||
@@ -142,7 +146,8 @@ run_pipeline() {
|
|||||||
-flush_packets 1 \
|
-flush_packets 1 \
|
||||||
-muxdelay 0 \
|
-muxdelay 0 \
|
||||||
-muxpreload 0 \
|
-muxpreload 0 \
|
||||||
-f mpegts \
|
-f rtsp \
|
||||||
|
-rtsp_transport tcp \
|
||||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ write_media_env_placeholder() {
|
|||||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||||
ROVERD_VIDEO_ENABLE=1
|
ROVERD_VIDEO_ENABLE=1
|
||||||
ROVERD_VIDEO_PUBLISHER=pi-libcamera
|
ROVERD_VIDEO_PUBLISHER=pi-libcamera
|
||||||
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
|
||||||
ROVERD_VIDEO_DEVICE=
|
ROVERD_VIDEO_DEVICE=
|
||||||
ROVERD_VIDEO_INPUT_FORMAT=
|
ROVERD_VIDEO_INPUT_FORMAT=
|
||||||
ROVERD_VIDEO_WIDTH=640
|
ROVERD_VIDEO_WIDTH=640
|
||||||
@@ -23,13 +23,13 @@ ROVERD_VIDEO_BITRATE=2000000
|
|||||||
ROVERD_VIDEO_INVERT=1
|
ROVERD_VIDEO_INVERT=1
|
||||||
ROVERD_VIDEO_SENSOR_MODE=1296:972
|
ROVERD_VIDEO_SENSOR_MODE=1296:972
|
||||||
ROVERD_AUDIO_CAPTURE_ENABLE=0
|
ROVERD_AUDIO_CAPTURE_ENABLE=0
|
||||||
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
|
||||||
ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0
|
ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0
|
||||||
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
||||||
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
||||||
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
||||||
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
||||||
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
|
||||||
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
|
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
|
||||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
||||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
||||||
@@ -43,7 +43,7 @@ ENV
|
|||||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||||
ROVERD_VIDEO_ENABLE=1
|
ROVERD_VIDEO_ENABLE=1
|
||||||
ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2
|
ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2
|
||||||
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
|
||||||
ROVERD_VIDEO_DEVICE=/dev/video0
|
ROVERD_VIDEO_DEVICE=/dev/video0
|
||||||
ROVERD_VIDEO_INPUT_FORMAT=mjpeg
|
ROVERD_VIDEO_INPUT_FORMAT=mjpeg
|
||||||
ROVERD_VIDEO_WIDTH=640
|
ROVERD_VIDEO_WIDTH=640
|
||||||
@@ -53,13 +53,13 @@ ROVERD_VIDEO_BITRATE=2000000
|
|||||||
ROVERD_VIDEO_INVERT=0
|
ROVERD_VIDEO_INVERT=0
|
||||||
ROVERD_VIDEO_SENSOR_MODE=
|
ROVERD_VIDEO_SENSOR_MODE=
|
||||||
ROVERD_AUDIO_CAPTURE_ENABLE=1
|
ROVERD_AUDIO_CAPTURE_ENABLE=1
|
||||||
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
|
||||||
ROVERD_AUDIO_CAPTURE_DEVICE=default
|
ROVERD_AUDIO_CAPTURE_DEVICE=default
|
||||||
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
||||||
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
||||||
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
||||||
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
||||||
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
|
||||||
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
|
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
|
||||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
||||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
||||||
|
|||||||
+57
-50
@@ -3,9 +3,11 @@ package roverd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -77,10 +79,10 @@ type HornConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MediaConfig struct {
|
type MediaConfig struct {
|
||||||
// PublishPort is shared by the derived video, microphone, and forwarded-audio
|
// RTSPPort is shared by the derived video, microphone, and forwarded-audio
|
||||||
// SRT URLs. Keeping it at this level prevents each nested block from needing
|
// RTSP URLs. Keeping it at this level prevents each nested block from needing
|
||||||
// to repeat the same server port when the common MediaMTX listener is used.
|
// to repeat the same server port when the common MediaMTX listener is used.
|
||||||
PublishPort int `yaml:"publishPort" json:"-"`
|
RTSPPort int `yaml:"rtspPort" json:"-"`
|
||||||
Manage bool `yaml:"manage" json:"manage"`
|
Manage bool `yaml:"manage" json:"manage"`
|
||||||
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
|
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
|
||||||
HealthInterval Duration `yaml:"healthInterval" json:"-"`
|
HealthInterval Duration `yaml:"healthInterval" json:"-"`
|
||||||
@@ -93,10 +95,12 @@ type VideoMediaConfig struct {
|
|||||||
// Publisher selects the installed publisher script/pipeline family. The
|
// Publisher selects the installed publisher script/pipeline family. The
|
||||||
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
|
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
|
||||||
// without changing the server-facing media shape again.
|
// without changing the server-facing media shape again.
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Service string `yaml:"service" json:"service,omitempty"`
|
Service string `yaml:"service" json:"service,omitempty"`
|
||||||
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
|
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
// PublishURL is derived during validation. It remains in rover metadata for server-side
|
||||||
|
// consumers, but is not a second hand-written endpoint in /etc/roverd.yaml.
|
||||||
|
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
|
||||||
Device string `yaml:"device" json:"device,omitempty"`
|
Device string `yaml:"device" json:"device,omitempty"`
|
||||||
InputFormat string `yaml:"inputFormat" json:"-"`
|
InputFormat string `yaml:"inputFormat" json:"-"`
|
||||||
Width int `yaml:"width" json:"-"`
|
Width int `yaml:"width" json:"-"`
|
||||||
@@ -111,9 +115,10 @@ type AudioCaptureConfig struct {
|
|||||||
// AudioCapture describes the rover microphone stream that browsers can
|
// AudioCapture describes the rover microphone stream that browsers can
|
||||||
// subscribe to as "<rover>-audio". A disabled capture block still has
|
// subscribe to as "<rover>-audio". A disabled capture block still has
|
||||||
// normalized defaults so enabling it only requires flipping enabled: true.
|
// normalized defaults so enabling it only requires flipping enabled: true.
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Service string `yaml:"service" json:"service,omitempty"`
|
Service string `yaml:"service" json:"service,omitempty"`
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
// PublishURL follows the same derived-only contract as the video path.
|
||||||
|
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
|
||||||
Device string `yaml:"device" json:"device,omitempty"`
|
Device string `yaml:"device" json:"device,omitempty"`
|
||||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||||
Channels int `yaml:"channels" json:"-"`
|
Channels int `yaml:"channels" json:"-"`
|
||||||
@@ -125,9 +130,10 @@ type AudioPlaybackConfig struct {
|
|||||||
// MediaMTX for playback on the rover speaker. The URL is a request/read URL
|
// MediaMTX for playback on the rover speaker. The URL is a request/read URL
|
||||||
// for the rover listener, while the server converts it to publish mode when
|
// for the rover listener, while the server converts it to publish mode when
|
||||||
// it needs to inject audio.
|
// it needs to inject audio.
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Service string `yaml:"service" json:"service,omitempty"`
|
Service string `yaml:"service" json:"service,omitempty"`
|
||||||
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"`
|
// ForwardURL is derived because the server and rover must agree on the exact -fwd path.
|
||||||
|
ForwardURL string `yaml:"-" json:"forwardUrl,omitempty"`
|
||||||
Device string `yaml:"device" json:"device,omitempty"`
|
Device string `yaml:"device" json:"device,omitempty"`
|
||||||
Normalize bool `yaml:"normalize" json:"-"`
|
Normalize bool `yaml:"normalize" json:"-"`
|
||||||
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
|
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
|
||||||
@@ -230,7 +236,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Media: MediaConfig{
|
Media: MediaConfig{
|
||||||
PublishPort: 9000,
|
RTSPPort: 8554,
|
||||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||||
Video: VideoMediaConfig{
|
Video: VideoMediaConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -349,8 +355,8 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if cfg.BRC.GPIOChip == "" {
|
if cfg.BRC.GPIOChip == "" {
|
||||||
cfg.BRC.GPIOChip = "gpiochip0"
|
cfg.BRC.GPIOChip = "gpiochip0"
|
||||||
}
|
}
|
||||||
if cfg.Media.PublishPort <= 0 {
|
if cfg.Media.RTSPPort <= 0 {
|
||||||
cfg.Media.PublishPort = 9000
|
cfg.Media.RTSPPort = 8554
|
||||||
}
|
}
|
||||||
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
|
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
|
||||||
return nil, fmt.Errorf("media: %w", err)
|
return nil, fmt.Errorf("media: %w", err)
|
||||||
@@ -432,25 +438,25 @@ func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) e
|
|||||||
file is written. This keeps the Pi behavior stable while making laptop
|
file is written. This keeps the Pi behavior stable while making laptop
|
||||||
and future publisher variants explicit configuration choices.
|
and future publisher variants explicit configuration choices.
|
||||||
*/
|
*/
|
||||||
if cfg.PublishPort <= 0 {
|
if cfg.RTSPPort <= 0 {
|
||||||
cfg.PublishPort = 9000
|
cfg.RTSPPort = 8554
|
||||||
}
|
}
|
||||||
if cfg.HealthInterval.Duration <= 0 {
|
if cfg.HealthInterval.Duration <= 0 {
|
||||||
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
|
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||||
}
|
}
|
||||||
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil {
|
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.RTSPPort); err != nil {
|
||||||
return fmt.Errorf("video: %w", err)
|
return fmt.Errorf("video: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil {
|
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.RTSPPort); err != nil {
|
||||||
return fmt.Errorf("audioCapture: %w", err)
|
return fmt.Errorf("audioCapture: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil {
|
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.RTSPPort); err != nil {
|
||||||
return fmt.Errorf("audioPlayback: %w", err)
|
return fmt.Errorf("audioPlayback: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error {
|
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, rtspPort int) error {
|
||||||
if cfg.Service == "" {
|
if cfg.Service == "" {
|
||||||
cfg.Service = "video-publisher.service"
|
cfg.Service = "video-publisher.service"
|
||||||
}
|
}
|
||||||
@@ -476,17 +482,19 @@ func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName
|
|||||||
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
|
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
|
||||||
cfg.SensorMode = "1296:972"
|
cfg.SensorMode = "1296:972"
|
||||||
}
|
}
|
||||||
if cfg.PublishURL == "" {
|
/*
|
||||||
derived, err := derivePublishURL(serverURL, roverName, publishPort)
|
Always derive this endpoint. Older rover configs can contain an explicit SRT publishUrl;
|
||||||
if err != nil {
|
honoring it after a binary update would silently leave that rover on the old transport.
|
||||||
return fmt.Errorf("derive publishUrl: %w", err)
|
*/
|
||||||
}
|
derived, err := derivePublishURL(serverURL, roverName, rtspPort)
|
||||||
cfg.PublishURL = derived
|
if err != nil {
|
||||||
|
return fmt.Errorf("derive publishUrl: %w", err)
|
||||||
}
|
}
|
||||||
|
cfg.PublishURL = derived
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error {
|
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, rtspPort int) error {
|
||||||
if cfg.Service == "" {
|
if cfg.Service == "" {
|
||||||
cfg.Service = "audio-only-publisher.service"
|
cfg.Service = "audio-only-publisher.service"
|
||||||
}
|
}
|
||||||
@@ -502,17 +510,15 @@ func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, rover
|
|||||||
if cfg.Bitrate <= 0 {
|
if cfg.Bitrate <= 0 {
|
||||||
cfg.Bitrate = 510000
|
cfg.Bitrate = 510000
|
||||||
}
|
}
|
||||||
if cfg.PublishURL == "" {
|
derived, err := derivePublishURL(serverURL, roverName+"-audio", rtspPort)
|
||||||
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("derive publishUrl: %w", err)
|
||||||
return fmt.Errorf("derive publishUrl: %w", err)
|
|
||||||
}
|
|
||||||
cfg.PublishURL = derived
|
|
||||||
}
|
}
|
||||||
|
cfg.PublishURL = derived
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error {
|
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, rtspPort int) error {
|
||||||
if cfg.Service == "" {
|
if cfg.Service == "" {
|
||||||
cfg.Service = "audio-forward-listener.service"
|
cfg.Service = "audio-forward-listener.service"
|
||||||
}
|
}
|
||||||
@@ -522,13 +528,11 @@ func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, rov
|
|||||||
if cfg.NormalizeFilter == "" {
|
if cfg.NormalizeFilter == "" {
|
||||||
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
|
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
|
||||||
}
|
}
|
||||||
if cfg.ForwardURL == "" {
|
derived, err := deriveReadURL(serverURL, roverName+"-fwd", rtspPort)
|
||||||
derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("derive forwardUrl: %w", err)
|
||||||
return fmt.Errorf("derive forwardUrl: %w", err)
|
|
||||||
}
|
|
||||||
cfg.ForwardURL = derived
|
|
||||||
}
|
}
|
||||||
|
cfg.ForwardURL = derived
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,20 +581,17 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||||
return deriveSRTURL(serverURL, streamName, port, "publish")
|
return deriveRTSPURL(serverURL, streamName, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
||||||
return deriveSRTURL(serverURL, streamName, port, "request")
|
return deriveRTSPURL(serverURL, streamName, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
|
func deriveRTSPURL(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")
|
||||||
}
|
}
|
||||||
if mode == "" {
|
|
||||||
mode = "publish"
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(serverURL)
|
parsed, err := url.Parse(serverURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -600,10 +601,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
|
|||||||
return "", errors.New("serverUrl missing host")
|
return "", errors.New("serverUrl missing host")
|
||||||
}
|
}
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
port = 9000
|
port = 8554
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
JoinHostPort handles both ordinary hostnames and bracketed IPv6 addresses. The rover name
|
||||||
|
is a MediaMTX path, so it is escaped independently instead of interpolated into the host.
|
||||||
|
RTSP distinguishes publishing from reading through protocol methods, which is why both
|
||||||
|
directions intentionally use the same URL shape.
|
||||||
|
*/
|
||||||
escaped := url.PathEscape(streamName)
|
escaped := url.PathEscape(streamName)
|
||||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
|
return fmt.Sprintf("rtsp://%s/%s", net.JoinHostPort(host, strconv.Itoa(port)), escaped), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
// These tests pin the network-agnostic RTSP contract. A rover provides its server URL and name
|
||||||
|
// once; all three media paths must then resolve to distinct, safely escaped MediaMTX paths.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaURLsDeriveFromServerURLAndRoverName(t *testing.T) {
|
||||||
|
cfg := MediaConfig{
|
||||||
|
Video: VideoMediaConfig{Enabled: true},
|
||||||
|
AudioCapture: AudioCaptureConfig{Enabled: true},
|
||||||
|
AudioPlayback: AudioPlaybackConfig{Enabled: true},
|
||||||
|
}
|
||||||
|
if err := validateMediaConfig(&cfg, "ws://control-server.local:8080/rover", "rover one"); err != nil {
|
||||||
|
t.Fatalf("validate media config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wants := map[string]string{
|
||||||
|
"video": "rtsp://control-server.local:8554/rover%20one",
|
||||||
|
"mic": "rtsp://control-server.local:8554/rover%20one-audio",
|
||||||
|
"speaker": "rtsp://control-server.local:8554/rover%20one-fwd",
|
||||||
|
}
|
||||||
|
got := map[string]string{
|
||||||
|
"video": cfg.Video.PublishURL,
|
||||||
|
"mic": cfg.AudioCapture.PublishURL,
|
||||||
|
"speaker": cfg.AudioPlayback.ForwardURL,
|
||||||
|
}
|
||||||
|
for name, want := range wants {
|
||||||
|
if got[name] != want {
|
||||||
|
t.Errorf("%s URL: got %q, want %q", name, got[name], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.RTSPPort != 8554 {
|
||||||
|
t.Fatalf("RTSP port: got %d, want 8554", cfg.RTSPPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitMediaPortAppliesToEveryRTSPPath(t *testing.T) {
|
||||||
|
cfg := MediaConfig{
|
||||||
|
RTSPPort: 10554,
|
||||||
|
Video: VideoMediaConfig{Enabled: true},
|
||||||
|
AudioCapture: AudioCaptureConfig{Enabled: true},
|
||||||
|
AudioPlayback: AudioPlaybackConfig{Enabled: true},
|
||||||
|
}
|
||||||
|
if err := validateMediaConfig(&cfg, "ws://media.example/rover", "r1"); err != nil {
|
||||||
|
t.Fatalf("validate media config: %v", err)
|
||||||
|
}
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(value, ":10554/") {
|
||||||
|
t.Errorf("%s URL did not use configured port: %q", name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyExplicitSRTURLsCannotKeepAnUpdatedRoverOnTheOldTransport(t *testing.T) {
|
||||||
|
/*
|
||||||
|
Deployed rover configs can still contain these former fields. Validation must replace
|
||||||
|
them unconditionally so updating roverd is sufficient to move the whole media path.
|
||||||
|
*/
|
||||||
|
cfg := MediaConfig{
|
||||||
|
Video: VideoMediaConfig{Enabled: true, PublishURL: "srt://old/video"},
|
||||||
|
AudioCapture: AudioCaptureConfig{Enabled: true, PublishURL: "srt://old/audio"},
|
||||||
|
AudioPlayback: AudioPlaybackConfig{Enabled: true, ForwardURL: "srt://old/forward"},
|
||||||
|
}
|
||||||
|
if err := validateMediaConfig(&cfg, "ws://new-server.local:8080/rover", "r1"); err != nil {
|
||||||
|
t.Fatalf("validate media config: %v", err)
|
||||||
|
}
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
|
||||||
|
} {
|
||||||
|
if !strings.HasPrefix(value, "rtsp://new-server.local:8554/") {
|
||||||
|
t.Errorf("%s retained an old transport URL: %q", name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ battery:
|
|||||||
maxWheelSpeed: 350
|
maxWheelSpeed: 350
|
||||||
|
|
||||||
media:
|
media:
|
||||||
publishPort: 9000
|
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
|
||||||
|
rtspPort: 8554
|
||||||
manage: true
|
manage: true
|
||||||
healthUrl: ""
|
healthUrl: ""
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ battery:
|
|||||||
urgent: 1650
|
urgent: 1650
|
||||||
maxWheelSpeed: 350
|
maxWheelSpeed: 350
|
||||||
media:
|
media:
|
||||||
publishPort: 9000
|
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
|
||||||
|
rtspPort: 8554
|
||||||
manage: true
|
manage: true
|
||||||
healthUrl: ""
|
healthUrl: ""
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
@@ -25,7 +26,6 @@ media:
|
|||||||
enabled: true
|
enabled: true
|
||||||
service: video-publisher.service
|
service: video-publisher.service
|
||||||
publisher: pi-libcamera
|
publisher: pi-libcamera
|
||||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
width: 640
|
width: 640
|
||||||
height: 480
|
height: 480
|
||||||
fps: 30
|
fps: 30
|
||||||
@@ -36,7 +36,6 @@ media:
|
|||||||
audioCapture:
|
audioCapture:
|
||||||
enabled: false
|
enabled: false
|
||||||
service: audio-only-publisher.service
|
service: audio-only-publisher.service
|
||||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
device: hw:0,0
|
device: hw:0,0
|
||||||
sampleRate: 48000
|
sampleRate: 48000
|
||||||
channels: 2
|
channels: 2
|
||||||
@@ -44,7 +43,6 @@ media:
|
|||||||
audioPlayback:
|
audioPlayback:
|
||||||
enabled: true
|
enabled: true
|
||||||
service: audio-forward-listener.service
|
service: audio-forward-listener.service
|
||||||
forwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
device: forward
|
device: forward
|
||||||
normalize: true
|
normalize: true
|
||||||
cameraServo:
|
cameraServo:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Multi-Roomba rover control agent
|
Description=Multi-Roomba rover control agent
|
||||||
After=network-online.target mediamtx.service
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -51,8 +51,15 @@ barcodeGames:
|
|||||||
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://media-server.local:8889/video
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
whepBaseUrl: "http://media-server.local:8889/video"
|
||||||
|
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
|
||||||
|
# candidates. Include every public and LAN address browsers use to reach this server.
|
||||||
|
# The server generates MediaMTX's runtime configuration from this list; never edit a
|
||||||
|
# separate mediamtx.yml for a new installation.
|
||||||
|
additionalHosts:
|
||||||
|
- "rover.example.com"
|
||||||
|
- "media-server.local"
|
||||||
|
|
||||||
bandwidthSavings:
|
bandwidthSavings:
|
||||||
# Duplicate driver-tab handling for the same browser identity.
|
# Duplicate driver-tab handling for the same browser identity.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ require('./src/services/serverControlService');
|
|||||||
require('./src/services/videoSessions');
|
require('./src/services/videoSessions');
|
||||||
require('./src/services/ptzCameraService');
|
require('./src/services/ptzCameraService');
|
||||||
require('./src/services/videoAuthService');
|
require('./src/services/videoAuthService');
|
||||||
|
require('./src/services/mediaMtxService');
|
||||||
require('./src/services/videoSocketService');
|
require('./src/services/videoSocketService');
|
||||||
require('./src/services/roomCameraService');
|
require('./src/services/roomCameraService');
|
||||||
require('./src/services/roverSnapshotService');
|
require('./src/services/roverSnapshotService');
|
||||||
|
|||||||
+23
-40
@@ -8,8 +8,6 @@ NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/downl
|
|||||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||||
NEOLINK_BIN="/usr/local/bin/neolink"
|
NEOLINK_BIN="/usr/local/bin/neolink"
|
||||||
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
||||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
|
||||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
|
||||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||||
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"
|
||||||
@@ -35,7 +33,6 @@ SERVER_DIR="$SCRIPT_DIR"
|
|||||||
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
|
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
|
||||||
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
|
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
|
||||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
|
||||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||||
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
||||||
|
|
||||||
@@ -259,53 +256,39 @@ if ! verify_google_tts_helper; then
|
|||||||
verify_google_tts_helper
|
verify_google_tts_helper
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
|
||||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
|
||||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
||||||
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ -f "$MEDIAMTX_CONFIG" ]]; then
|
|
||||||
echo " Preserving existing mediaMTX config -> $MEDIAMTX_CONFIG"
|
|
||||||
else
|
|
||||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
|
||||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
|
||||||
fi
|
|
||||||
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
|
||||||
|
# Validate the new source of truth before disabling a working legacy service. The validator
|
||||||
|
# performs the same build and YAML serialization as server startup without opening listeners
|
||||||
|
# or leaving a process behind.
|
||||||
|
runuser -u "$TARGET_USER" -- env \
|
||||||
|
SERVER_CONFIG="$CONFIG_PATH" \
|
||||||
|
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
|
||||||
|
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
|
||||||
|
|
||||||
|
# MediaMTX used to run as its own systemd service with a hand-maintained config in
|
||||||
|
# /etc/mediamtx. Stop it before multirover starts the new child process, otherwise the two
|
||||||
|
# processes race for every media listener. Both commands are deliberately idempotent so an
|
||||||
|
# already-migrated server and a first-time installation follow the same path.
|
||||||
|
echo " Disabling legacy mediamtx.service"
|
||||||
|
systemctl disable --now mediamtx.service 2>/dev/null || true
|
||||||
|
rm -f "$MEDIAMTX_SERVICE"
|
||||||
|
rm -f /etc/mediamtx/mediamtx.yml
|
||||||
|
|
||||||
echo "[4/6] Writing systemd units..."
|
echo "[4/6] Writing systemd units..."
|
||||||
mkdir -p "$SNAPSHOT_DIR"
|
mkdir -p "$SNAPSHOT_DIR"
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
||||||
mkdir -p "$REPLAY_SEGMENT_DIR"
|
mkdir -p "$REPLAY_SEGMENT_DIR"
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
||||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=mediaMTX WebRTC Server
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=$TARGET_USER
|
|
||||||
Group=$TARGET_USER
|
|
||||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
|
||||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
|
||||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Multi-Roomba Rover control server
|
Description=Multi-Roomba Rover control server
|
||||||
After=network-online.target mediamtx.service bluetooth.service
|
After=network-online.target bluetooth.service
|
||||||
Wants=network-online.target bluetooth.service
|
Wants=network-online.target bluetooth.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -316,6 +299,9 @@ Environment=NODE_ENV=production
|
|||||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||||
|
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
|
||||||
|
RuntimeDirectory=multirover
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
@@ -325,20 +311,17 @@ SuccessExitStatus=130 143
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
chmod 644 "$MULTIROVER_SERVICE"
|
||||||
|
|
||||||
echo "[5/6] Enabling services..."
|
echo "[5/6] Enabling services..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now mediamtx.service
|
|
||||||
systemctl enable --now multirover.service
|
systemctl enable --now multirover.service
|
||||||
systemctl restart mediamtx.service
|
|
||||||
systemctl restart multirover.service
|
systemctl restart multirover.service
|
||||||
|
|
||||||
echo "[6/6] Done."
|
echo "[6/6] Done."
|
||||||
echo
|
echo
|
||||||
echo "Services installed:"
|
echo "Services installed:"
|
||||||
echo " mediamtx.service (WebRTC fan-out)"
|
echo " multirover.service (Node.js control server with MediaMTX child)"
|
||||||
echo " multirover.service (Node.js control server)"
|
|
||||||
echo
|
echo
|
||||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
echo "Kinect/libfreenect packages and udev permissions were installed."
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
|
|
||||||
logLevel: info
|
|
||||||
|
|
||||||
api: yes
|
|
||||||
apiAddress: 0.0.0.0:9997
|
|
||||||
metrics: yes
|
|
||||||
metricsAddress: 0.0.0.0:9998
|
|
||||||
pprof: no
|
|
||||||
pprofAddress: 127.0.0.1:9999
|
|
||||||
|
|
||||||
rtsp: no
|
|
||||||
rtmp: no
|
|
||||||
hls: no
|
|
||||||
|
|
||||||
webrtc: yes
|
|
||||||
webrtcLocalUDPAddress: :8189
|
|
||||||
webrtcLocalTCPAddress: :8189
|
|
||||||
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
|
|
||||||
webrtcICEServers2:
|
|
||||||
# Google public STUN (world-wide, very commonly used)
|
|
||||||
- url: stun:stun.l.google.com:19302
|
|
||||||
- url: stun:stun1.l.google.com:19302
|
|
||||||
- url: stun:stun2.l.google.com:19302
|
|
||||||
- url: stun:stun3.l.google.com:19302
|
|
||||||
- url: stun:stun4.l.google.com:19302
|
|
||||||
|
|
||||||
# Cloudflare STUN (anycast, global PoPs)
|
|
||||||
- url: stun:stun.cloudflare.com:3478
|
|
||||||
|
|
||||||
srt: yes
|
|
||||||
srtAddress: :9000
|
|
||||||
|
|
||||||
authMethod: http
|
|
||||||
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
|
||||||
authHTTPExclude:
|
|
||||||
- action: api
|
|
||||||
- action: metrics
|
|
||||||
- action: pprof
|
|
||||||
|
|
||||||
paths:
|
|
||||||
all:
|
|
||||||
source: publisher
|
|
||||||
sourceOnDemand: no
|
|
||||||
# Rover Snapshot Writer
|
|
||||||
# Keep rover snapshots continuously updated while a rover video path is live.
|
|
||||||
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
|
|
||||||
runOnReadyRestart: yes
|
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// MediaMTX Configuration Validator
|
||||||
|
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
|
||||||
|
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const { loadConfig } = require('../src/helpers/configLoader');
|
||||||
|
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const generated = buildMediaMtxConfig({
|
||||||
|
config,
|
||||||
|
serverPort: process.env.PORT || 8080,
|
||||||
|
snapshotWriterPath: process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Serializing is part of validation: it catches values that the builder accepted but js-yaml
|
||||||
|
cannot represent before the installer removes the previous service configuration.
|
||||||
|
*/
|
||||||
|
yaml.dump(generated, { noRefs: true, lineWidth: 120 });
|
||||||
|
process.stdout.write('MediaMTX server configuration is valid\n');
|
||||||
@@ -30,25 +30,13 @@ function createAudioForwardPolicy(deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function forcePublishStreamMode(rawUrl) {
|
|
||||||
const value = String(rawUrl || '').trim();
|
|
||||||
if (!value) return '';
|
|
||||||
if (!/[?&]streamid=#!::/.test(value)) return value;
|
|
||||||
if (/,m=publish\b/.test(value)) return value;
|
|
||||||
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
|
||||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveForwardUrl(roverId) {
|
function resolveForwardUrl(roverId) {
|
||||||
const record = roverManager.rovers.get(roverId);
|
/*
|
||||||
// Rovers listen to the playback stream with a request/read URL. The VIP
|
The server publishes to its own MediaMTX child, so loopback is the stable and correct
|
||||||
// upload path needs to publish into that same stream, so the configured
|
route regardless of which hostname a rover uses to reach this machine. RTSP uses the
|
||||||
// nested playback URL is converted to publish mode below.
|
same path for publish and read; ANNOUNCE/RECORD and DESCRIBE/PLAY distinguish direction.
|
||||||
const configured = record?.meta?.media?.audioPlayback?.forwardUrl;
|
*/
|
||||||
if (configured) return forcePublishStreamMode(configured);
|
return `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId + streamSuffix)}`;
|
||||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
|
|
||||||
roverId + streamSuffix,
|
|
||||||
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveForwardPathId(roverId) {
|
function resolveForwardPathId(roverId) {
|
||||||
|
|||||||
@@ -25,3 +25,8 @@ test('preserves normal audio forwarding for an unmuted verified driver', () => {
|
|||||||
const policy = createPolicy();
|
const policy = createPolicy();
|
||||||
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
|
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
|
||||||
|
const policy = createPolicy();
|
||||||
|
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
|
||||||
|
});
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function createAudioForwardWorkerEngine(deps) {
|
|||||||
exited = true;
|
exited = true;
|
||||||
};
|
};
|
||||||
// ChildProcess.killed only means Node successfully sent a signal, not that
|
// ChildProcess.killed only means Node successfully sent a signal, not that
|
||||||
// ffmpeg actually exited. Track the real exit event so FIFO/SRT hangs still
|
// ffmpeg actually exited. Track the real exit event so FIFO/publisher hangs still
|
||||||
// get escalated to SIGKILL instead of making systemd wait for its timeout.
|
// get escalated to SIGKILL instead of making systemd wait for its timeout.
|
||||||
proc.once('exit', markExited);
|
proc.once('exit', markExited);
|
||||||
try {
|
try {
|
||||||
@@ -145,7 +145,13 @@ function createAudioForwardWorkerEngine(deps) {
|
|||||||
'-muxpreload',
|
'-muxpreload',
|
||||||
'0',
|
'0',
|
||||||
'-f',
|
'-f',
|
||||||
'mpegts',
|
'rtsp',
|
||||||
|
/*
|
||||||
|
The MediaMTX listener accepts RTSP over TCP only. Pinning it here makes the server's
|
||||||
|
own publisher follow the same reliable transport contract as every rover publisher.
|
||||||
|
*/
|
||||||
|
'-rtsp_transport',
|
||||||
|
'tcp',
|
||||||
outputUrl,
|
outputUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
const { httpServer } = require('../../globals/http');
|
const { httpServer } = require('../../globals/http');
|
||||||
const config = require('../../globals/config');
|
const config = require('../../globals/config');
|
||||||
const logger = require('../../globals/logger').child('httpServer');
|
const logger = require('../../globals/logger').child('httpServer');
|
||||||
|
const { startMediaMtx } = require('../mediaMtxService');
|
||||||
|
|
||||||
httpServer.listen(config.port, () => {
|
httpServer.listen(config.port, () => {
|
||||||
logger.info(`Server listening on :${config.port}`);
|
logger.info(`Server listening on :${config.port}`);
|
||||||
|
/*
|
||||||
|
MediaMTX immediately calls the server's HTTP authorization route when clients connect.
|
||||||
|
Starting it from the listen callback guarantees that endpoint is reachable before the
|
||||||
|
first publisher attempts to authenticate.
|
||||||
|
*/
|
||||||
|
startMediaMtx();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// MediaMTX Config Builder
|
||||||
|
// Purpose: Converts the rover server's media settings into the complete MediaMTX runtime configuration.
|
||||||
|
// Scope: Keeps deployment-specific hosts in config.yaml while keeping protocol policy owned by the application.
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function normalizeAdditionalHosts(rawHosts) {
|
||||||
|
if (rawHosts == null) return [];
|
||||||
|
if (!Array.isArray(rawHosts)) {
|
||||||
|
throw new Error('media.additionalHosts must be a list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
MediaMTX accepts both IP addresses and DNS names here. Preserve that flexibility because
|
||||||
|
an installation can need a public candidate and a LAN candidate at the same time. Empty
|
||||||
|
entries and duplicates are removed so a harmless config typo does not create redundant
|
||||||
|
ICE candidates, while the values themselves remain entirely instance-owned.
|
||||||
|
*/
|
||||||
|
return [...new Set(rawHosts.map((value) => String(value || '').trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||||
|
const media = config?.media || {};
|
||||||
|
let additionalHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||||
|
if (!additionalHosts.length && media.whepBaseUrl) {
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
Existing installations predate media.additionalHosts. Using the already-configured
|
||||||
|
WHEP hostname as a one-host migration default keeps them reachable on first restart;
|
||||||
|
administrators can still list every public and LAN candidate explicitly afterward.
|
||||||
|
*/
|
||||||
|
additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const authPort = Number(serverPort) || 8080;
|
||||||
|
|
||||||
|
return {
|
||||||
|
logLevel: 'info',
|
||||||
|
api: true,
|
||||||
|
apiAddress: '127.0.0.1:9997',
|
||||||
|
metrics: true,
|
||||||
|
metricsAddress: '127.0.0.1:9998',
|
||||||
|
pprof: false,
|
||||||
|
pprofAddress: '127.0.0.1:9999',
|
||||||
|
|
||||||
|
/*
|
||||||
|
Rover publishers and readers always request TCP explicitly. Declaring only TCP here
|
||||||
|
also prevents MediaMTX from opening the separate RTP/RTCP UDP listeners, which are not
|
||||||
|
useful for this local-network deployment and performed poorly in the measured tests.
|
||||||
|
*/
|
||||||
|
rtsp: true,
|
||||||
|
rtspAddress: ':8554',
|
||||||
|
rtspTransports: ['tcp'],
|
||||||
|
rtmp: false,
|
||||||
|
hls: false,
|
||||||
|
|
||||||
|
webrtc: true,
|
||||||
|
webrtcLocalUDPAddress: ':8189',
|
||||||
|
webrtcLocalTCPAddress: ':8189',
|
||||||
|
webrtcAdditionalHosts: additionalHosts,
|
||||||
|
webrtcICEServers2: [
|
||||||
|
{ url: 'stun:stun.l.google.com:19302' },
|
||||||
|
{ url: 'stun:stun1.l.google.com:19302' },
|
||||||
|
{ url: 'stun:stun2.l.google.com:19302' },
|
||||||
|
{ url: 'stun:stun3.l.google.com:19302' },
|
||||||
|
{ url: 'stun:stun4.l.google.com:19302' },
|
||||||
|
{ url: 'stun:stun.cloudflare.com:3478' },
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot
|
||||||
|
writer. Rover media moves to RTSP, but removing this listener would break those independent
|
||||||
|
consumers, so both listeners remain deliberately enabled.
|
||||||
|
*/
|
||||||
|
srt: true,
|
||||||
|
srtAddress: ':9000',
|
||||||
|
|
||||||
|
authMethod: 'http',
|
||||||
|
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
|
||||||
|
authHTTPExclude: [
|
||||||
|
{ action: 'api' },
|
||||||
|
{ action: 'metrics' },
|
||||||
|
{ action: 'pprof' },
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
all: {
|
||||||
|
source: 'publisher',
|
||||||
|
sourceOnDemand: false,
|
||||||
|
runOnReady: path.resolve(snapshotWriterPath),
|
||||||
|
runOnReadyRestart: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildMediaMtxConfig,
|
||||||
|
normalizeAdditionalHosts,
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// MediaMTX Config Builder Tests
|
||||||
|
// Purpose: Pins the generated protocol policy and instance-specific ICE host handling.
|
||||||
|
// Scope: Tests pure configuration output without starting listeners or leaving a child process running.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
|
||||||
|
|
||||||
|
test('generates RTSP over TCP without deployment-specific hardcodes', () => {
|
||||||
|
const generated = buildMediaMtxConfig({
|
||||||
|
config: {
|
||||||
|
media: {
|
||||||
|
whepBaseUrl: 'http://media.internal:8889/video',
|
||||||
|
additionalHosts: ['public.example.com', '10.20.30.40'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serverPort: 8123,
|
||||||
|
snapshotWriterPath: '/opt/multirover/rover-snapshot-writer.sh',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(generated.rtsp, true);
|
||||||
|
assert.equal(generated.rtspAddress, ':8554');
|
||||||
|
assert.deepEqual(generated.rtspTransports, ['tcp']);
|
||||||
|
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
|
||||||
|
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
|
||||||
|
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
|
||||||
|
assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => {
|
||||||
|
const generated = buildMediaMtxConfig({
|
||||||
|
config: { media: { whepBaseUrl: 'https://second-server.example/video' } },
|
||||||
|
serverPort: 8080,
|
||||||
|
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(generated.webrtcAdditionalHosts, ['second-server.example']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes duplicate and empty additional hosts', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeAdditionalHosts([' media.local ', '', 'media.local', null, 'public.example']),
|
||||||
|
['media.local', 'public.example'],
|
||||||
|
);
|
||||||
|
assert.throws(() => normalizeAdditionalHosts('media.local'), /must be a list/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// MediaMTX Service
|
||||||
|
// Purpose: Composes server configuration, runtime paths, and child-process supervision.
|
||||||
|
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const globalConfig = require('../../globals/config');
|
||||||
|
const logger = require('../../globals/logger').child('mediamtx');
|
||||||
|
const { createMediaMtxSupervisor } = require('./supervisor');
|
||||||
|
|
||||||
|
const supervisor = createMediaMtxSupervisor({
|
||||||
|
config: loadConfig(),
|
||||||
|
serverPort: globalConfig.port,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
function startMediaMtx() {
|
||||||
|
return supervisor.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Other services already use process signal hooks for their own workers. This hook performs
|
||||||
|
only synchronous signal delivery; systemd's default control-group cleanup remains the final
|
||||||
|
guarantee if the parent is killed before the child finishes exiting.
|
||||||
|
*/
|
||||||
|
process.once('exit', () => supervisor.stop());
|
||||||
|
|
||||||
|
function stopForSignal(signal) {
|
||||||
|
let completed = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (completed) return;
|
||||||
|
completed = true;
|
||||||
|
process.exit(signal === 'SIGINT' ? 130 : 143);
|
||||||
|
};
|
||||||
|
|
||||||
|
supervisor.stop(finish);
|
||||||
|
/*
|
||||||
|
A wedged child must not make systemd wait indefinitely. This timer is deliberately unref'd
|
||||||
|
so it never keeps an otherwise-finished process alive; it is only a bound on graceful exit.
|
||||||
|
*/
|
||||||
|
const forceExitTimer = setTimeout(finish, 5000);
|
||||||
|
forceExitTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('SIGINT', () => stopForSignal('SIGINT'));
|
||||||
|
process.once('SIGTERM', () => stopForSignal('SIGTERM'));
|
||||||
|
|
||||||
|
module.exports = { startMediaMtx };
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// MediaMTX Child Supervisor
|
||||||
|
// Purpose: Writes the generated runtime configuration and owns the MediaMTX child process lifecycle.
|
||||||
|
// Scope: Starts exactly one child, forwards its logs, and lets systemd restart the coherent server/media pair.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const { buildMediaMtxConfig } = require('./config');
|
||||||
|
|
||||||
|
function createMediaMtxSupervisor(deps) {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
serverPort,
|
||||||
|
logger,
|
||||||
|
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
|
||||||
|
runtimeDir = process.env.MULTIROVER_RUNTIME_DIR || '/run/multirover',
|
||||||
|
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
|
||||||
|
spawnProcess = spawn,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
let child = null;
|
||||||
|
let stopping = false;
|
||||||
|
let stoppedCallback = null;
|
||||||
|
|
||||||
|
function forwardLines(stream, level) {
|
||||||
|
let pending = '';
|
||||||
|
stream.setEncoding('utf8');
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
pending += chunk;
|
||||||
|
const lines = pending.split(/\r?\n/);
|
||||||
|
pending = lines.pop() || '';
|
||||||
|
lines.filter(Boolean).forEach((line) => logger[level](line));
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (pending) logger[level](pending);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (child) return child;
|
||||||
|
|
||||||
|
const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
|
||||||
|
const configPath = path.join(runtimeDir, 'mediamtx.yml');
|
||||||
|
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 });
|
||||||
|
fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
|
||||||
|
|
||||||
|
logger.info(`Starting MediaMTX with generated config ${configPath}`);
|
||||||
|
child = spawnProcess(mediaMtxBin, [configPath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
forwardLines(child.stdout, 'info');
|
||||||
|
forwardLines(child.stderr, 'warn');
|
||||||
|
|
||||||
|
child.once('error', (err) => {
|
||||||
|
logger.error('Unable to start MediaMTX', err);
|
||||||
|
if (!stopping) {
|
||||||
|
/*
|
||||||
|
A spawn failure does not reliably emit the normal exit event on every platform.
|
||||||
|
Fail the parent here as well so the server can never stay nominally online without
|
||||||
|
its required media child and systemd gets the opportunity to repair the launch.
|
||||||
|
*/
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
child = null;
|
||||||
|
if (stopping) {
|
||||||
|
stoppedCallback?.();
|
||||||
|
stoppedCallback = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
MediaMTX is required for every live media path. Exiting the parent is intentionally
|
||||||
|
simpler and safer than maintaining a second retry policy inside Node: systemd already
|
||||||
|
restarts multirover.service, producing one clean server/MediaMTX lifecycle.
|
||||||
|
*/
|
||||||
|
logger.error(`MediaMTX exited unexpectedly (code=${code ?? 'none'} signal=${signal || 'none'})`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(onStopped) {
|
||||||
|
stopping = true;
|
||||||
|
stoppedCallback = typeof onStopped === 'function' ? onStopped : null;
|
||||||
|
if (!child) {
|
||||||
|
stoppedCallback?.();
|
||||||
|
stoppedCallback = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Unable to stop MediaMTX cleanly', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createMediaMtxSupervisor };
|
||||||
@@ -33,6 +33,7 @@ function registerVideoAuthRoute(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
|
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
|
||||||
|
const isRtspProtocol = protocol === 'rtsp' || protocol.startsWith('rtsp');
|
||||||
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
|
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
|
||||||
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
|
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
@@ -40,6 +41,15 @@ function registerVideoAuthRoute(deps) {
|
|||||||
if (action === 'publish' && isSrtLikeProtocol) {
|
if (action === 'publish' && isSrtLikeProtocol) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
Rover and server publishers reach MediaMTX only on the local network and intentionally
|
||||||
|
do not carry browser session credentials. MediaMTX still invokes its global HTTP auth
|
||||||
|
callback for RTSP, so explicitly admit that publish protocol while leaving WHEP reads
|
||||||
|
under the existing session and role checks below.
|
||||||
|
*/
|
||||||
|
if (action === 'publish' && isRtspProtocol) {
|
||||||
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Video Auth HTTP Route Tests
|
||||||
|
// Purpose: Verifies credential-free LAN RTSP publishing without weakening browser read authorization.
|
||||||
|
// Scope: Invokes the registered route with lightweight request/response doubles.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { registerVideoAuthRoute } = require('./httpRoute');
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
let handler;
|
||||||
|
const app = { post: (_path, fn) => { handler = fn; } };
|
||||||
|
registerVideoAuthRoute({
|
||||||
|
app,
|
||||||
|
io: { sockets: { sockets: new Map() } },
|
||||||
|
logger: { info() {}, warn() {} },
|
||||||
|
videoSessions: { getSession: () => null, revokeSession() {} },
|
||||||
|
getRequestIp: () => '127.0.0.1',
|
||||||
|
logAdminEvent() {},
|
||||||
|
extractStreamInfoFromBody: (body) => ({ type: 'rover', id: body.path, baseId: body.path }),
|
||||||
|
canAccessStream: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function request(body) {
|
||||||
|
const result = { statusCode: null };
|
||||||
|
const response = {
|
||||||
|
status(code) {
|
||||||
|
result.statusCode = code;
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handler({ body }, response);
|
||||||
|
return result.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('allows an RTSP rover publisher without a browser session', () => {
|
||||||
|
const { request } = createHarness();
|
||||||
|
assert.equal(request({ protocol: 'rtsp', action: 'publish', path: 'rover-one' }), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('continues rejecting an unauthenticated WebRTC read', () => {
|
||||||
|
const { request } = createHarness();
|
||||||
|
assert.equal(request({ protocol: 'webrtc', action: 'read', path: 'rover-one' }), 401);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user