Merge pull request #22 from legop3/transportswap

Transportswap merge
This commit is contained in:
legop3
2026-08-05 14:55:30 -04:00
committed by GitHub
34 changed files with 658 additions and 191 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5 -3
View File
@@ -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
) )
+20 -8
View File
@@ -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=""
@@ -88,6 +87,7 @@ else
fi fi
run_pipeline() { run_pipeline() {
local -a pipeline_statuses=()
local ffmpeg_args=( local ffmpeg_args=(
-hide_banner -hide_banner
-loglevel warning -loglevel warning
@@ -123,13 +123,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}"
) )
@@ -146,6 +147,17 @@ run_pipeline() {
# latency compared with the old 65,536-byte buffer. # latency compared with the old 65,536-byte buffer.
arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \ arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \
| "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}" | "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}"
pipeline_statuses=("${PIPESTATUS[@]}")
# PIPESTATUS belongs to the pipeline that just finished and is replaced by the next shell
# command. Capture it immediately, then return the publisher failure first because that is
# normally the reason arecord receives a secondary broken pipe.
LAST_ARECORD_STATUS="${pipeline_statuses[0]:-unknown}"
LAST_FFMPEG_STATUS="${pipeline_statuses[1]:-unknown}"
if [[ "${LAST_FFMPEG_STATUS}" != "0" ]]; then
return "${LAST_FFMPEG_STATUS}"
fi
return "${LAST_ARECORD_STATUS}"
} }
trap 'kill 0 2>/dev/null' EXIT INT TERM trap 'kill 0 2>/dev/null' EXIT INT TERM
@@ -154,6 +166,6 @@ while true; do
if run_pipeline; then if run_pipeline; then
exit 0 exit 0
fi fi
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2 echo "Audio-only publisher exited arecord=${LAST_ARECORD_STATUS:-unknown} ffmpeg=${LAST_FFMPEG_STATUS:-unknown}, restarting in 2s..." >&2
sleep 2 sleep 2
done done
+6 -4
View File
@@ -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}"
} }
+37
View File
@@ -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"
+6 -1
View File
@@ -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}"
} }
+6 -6
View File
@@ -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
+41 -34
View File
@@ -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:"-"`
@@ -96,7 +98,9 @@ type VideoMediaConfig struct {
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:"-"`
@@ -113,7 +117,8 @@ type AudioCaptureConfig struct {
// 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:"-"`
@@ -127,7 +132,8 @@ type AudioPlaybackConfig struct {
// 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;
honoring it after a binary update would silently leave that rover on the old transport.
*/
derived, err := derivePublishURL(serverURL, roverName, rtspPort)
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 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}$`)
+80
View File
@@ -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)
}
}
}
+2 -1
View File
@@ -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
+2 -4
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Audio Forward Listener (SRT -> ALSA) Description=Rover Audio Forward Listener (RTSP/TCP -> ALSA)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Audio Publisher (ALSA -> SRT) Description=Rover Audio Publisher (ALSA -> RTSP/TCP)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Debian Laptop Video Publisher (V4L2 -> SRT) Description=Rover Debian Laptop Video Publisher (V4L2 -> RTSP/TCP)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Video Publisher (libcamera -> SRT) Description=Rover Video Publisher (libcamera -> RTSP/TCP)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
+9 -2
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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."
-47
View File
@@ -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
+21
View File
@@ -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,
]; ];
} }
+7
View File
@@ -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);
});