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
fi
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The
# forward URL is data produced by roverd, and treating it as shell code would
# break on normal SRT query-string characters such as '&'.
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The forward URL is
# data produced by roverd and must never be interpreted as executable shell code.
load_env_file() {
local content=""
@@ -95,6 +94,9 @@ run_pipeline() {
-flags low_delay
-analyzeduration 200k
-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}"
-vn
)
+20 -8
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1
fi
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs
# contain characters such as '&' and '#!', so sourcing this file would treat a
# data file as code and can split a valid URL into shell control operators.
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. URLs are data;
# sourcing this file would unnecessarily treat server-provided values as shell code.
load_env_file() {
local content=""
@@ -88,6 +87,7 @@ else
fi
run_pipeline() {
local -a pipeline_statuses=()
local ffmpeg_args=(
-hide_banner
-loglevel warning
@@ -123,13 +123,14 @@ run_pipeline() {
-frame_duration 20
-compression_level 0
# Mirror the video publisher's MPEG-TS low-latency settings. Without
# these, ffmpeg is allowed to hold packets for mux timing, which is
# exactly the wrong tradeoff for live rover feedback.
# RTSP carries the existing Opus stream directly, avoiding MediaMTX's costly
# MPEG-TS demux without changing microphone capture or encoding quality. TCP is
# required for the same reliable local-network behavior as the video publisher.
-flush_packets 1
-muxdelay 0
-muxpreload 0
-f mpegts
-f rtsp
-rtsp_transport tcp
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
)
@@ -146,6 +147,17 @@ run_pipeline() {
# 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 \
| "${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
@@ -154,6 +166,6 @@ while true; do
if run_pipeline; then
exit 0
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
done
+6 -4
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1
fi
# Load roverd's generated media.env as data instead of sourcing it as shell.
# The SRT publish URL contains normal query-string characters like '&' and '#!',
# so evaluating the file would be both fragile and unnecessary.
# Load roverd's generated media.env as data instead of sourcing it as shell. URLs are
# configuration data, so evaluating the file would be both fragile and unnecessary.
load_env_file() {
local content=""
@@ -103,6 +102,8 @@ if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
fi
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}" \
-hide_banner \
-loglevel warning \
@@ -130,7 +131,8 @@ run_pipeline() {
-flush_packets 1 \
-muxdelay 0 \
-muxpreload 0 \
-f mpegts \
-f rtsp \
-rtsp_transport tcp \
"${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
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}" \
--inline \
--timeout 0 \
@@ -142,7 +146,8 @@ run_pipeline() {
-flush_packets 1 \
-muxdelay 0 \
-muxpreload 0 \
-f mpegts \
-f rtsp \
-rtsp_transport tcp \
"${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.
ROVERD_VIDEO_ENABLE=1
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_INPUT_FORMAT=
ROVERD_VIDEO_WIDTH=640
@@ -23,13 +23,13 @@ ROVERD_VIDEO_BITRATE=2000000
ROVERD_VIDEO_INVERT=1
ROVERD_VIDEO_SENSOR_MODE=1296:972
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_SAMPLE_RATE=48000
ROVERD_AUDIO_CAPTURE_CHANNELS=2
ROVERD_AUDIO_CAPTURE_BITRATE=510000
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_NORMALIZE=1
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.
ROVERD_VIDEO_ENABLE=1
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_INPUT_FORMAT=mjpeg
ROVERD_VIDEO_WIDTH=640
@@ -53,13 +53,13 @@ ROVERD_VIDEO_BITRATE=2000000
ROVERD_VIDEO_INVERT=0
ROVERD_VIDEO_SENSOR_MODE=
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_SAMPLE_RATE=48000
ROVERD_AUDIO_CAPTURE_CHANNELS=2
ROVERD_AUDIO_CAPTURE_BITRATE=510000
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_NORMALIZE=1
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
+57 -50
View File
@@ -3,9 +3,11 @@ package roverd
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -77,10 +79,10 @@ type HornConfig struct {
}
type MediaConfig struct {
// PublishPort is shared by the derived video, microphone, and forwarded-audio
// SRT URLs. Keeping it at this level prevents each nested block from needing
// RTSPPort is shared by the derived video, microphone, and forwarded-audio
// 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.
PublishPort int `yaml:"publishPort" json:"-"`
RTSPPort int `yaml:"rtspPort" json:"-"`
Manage bool `yaml:"manage" json:"manage"`
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
HealthInterval Duration `yaml:"healthInterval" json:"-"`
@@ -93,10 +95,12 @@ type VideoMediaConfig struct {
// Publisher selects the installed publisher script/pipeline family. The
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
// without changing the server-facing media shape again.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
Publisher string `yaml:"publisher" json:"publisher,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"`
InputFormat string `yaml:"inputFormat" json:"-"`
Width int `yaml:"width" json:"-"`
@@ -111,9 +115,10 @@ type AudioCaptureConfig struct {
// AudioCapture describes the rover microphone stream that browsers can
// subscribe to as "<rover>-audio". A disabled capture block still has
// normalized defaults so enabling it only requires flipping enabled: true.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,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"`
SampleRate int `yaml:"sampleRate" 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
// for the rover listener, while the server converts it to publish mode when
// it needs to inject audio.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,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"`
Normalize bool `yaml:"normalize" json:"-"`
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
@@ -230,7 +236,7 @@ func LoadConfig(path string) (*Config, error) {
},
},
Media: MediaConfig{
PublishPort: 9000,
RTSPPort: 8554,
HealthInterval: Duration{Duration: 30 * time.Second},
Video: VideoMediaConfig{
Enabled: true,
@@ -349,8 +355,8 @@ func LoadConfig(path string) (*Config, error) {
if cfg.BRC.GPIOChip == "" {
cfg.BRC.GPIOChip = "gpiochip0"
}
if cfg.Media.PublishPort <= 0 {
cfg.Media.PublishPort = 9000
if cfg.Media.RTSPPort <= 0 {
cfg.Media.RTSPPort = 8554
}
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
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
and future publisher variants explicit configuration choices.
*/
if cfg.PublishPort <= 0 {
cfg.PublishPort = 9000
if cfg.RTSPPort <= 0 {
cfg.RTSPPort = 8554
}
if cfg.HealthInterval.Duration <= 0 {
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)
}
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)
}
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 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 == "" {
cfg.Service = "video-publisher.service"
}
@@ -476,17 +482,19 @@ func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
cfg.SensorMode = "1296:972"
}
if cfg.PublishURL == "" {
derived, err := derivePublishURL(serverURL, roverName, publishPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
/*
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 {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
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 == "" {
cfg.Service = "audio-only-publisher.service"
}
@@ -502,17 +510,15 @@ func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, rover
if cfg.Bitrate <= 0 {
cfg.Bitrate = 510000
}
if cfg.PublishURL == "" {
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
derived, err := derivePublishURL(serverURL, roverName+"-audio", rtspPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
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 == "" {
cfg.Service = "audio-forward-listener.service"
}
@@ -522,13 +528,11 @@ func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, rov
if cfg.NormalizeFilter == "" {
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", publishPort)
if err != nil {
return fmt.Errorf("derive forwardUrl: %w", err)
}
cfg.ForwardURL = derived
derived, err := deriveReadURL(serverURL, roverName+"-fwd", rtspPort)
if err != nil {
return fmt.Errorf("derive forwardUrl: %w", err)
}
cfg.ForwardURL = derived
return nil
}
@@ -577,20 +581,17 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
}
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) {
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 == "" {
return "", errors.New("missing stream name for publishUrl")
}
if mode == "" {
mode = "publish"
}
parsed, err := url.Parse(serverURL)
if err != nil {
return "", err
@@ -600,10 +601,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
return "", errors.New("serverUrl missing host")
}
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)
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}$`)
+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
media:
publishPort: 9000
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
rtspPort: 8554
manage: true
healthUrl: ""
healthInterval: 30s
+2 -4
View File
@@ -17,7 +17,8 @@ battery:
urgent: 1650
maxWheelSpeed: 350
media:
publishPort: 9000
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
rtspPort: 8554
manage: true
healthUrl: ""
healthInterval: 30s
@@ -25,7 +26,6 @@ media:
enabled: true
service: video-publisher.service
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
height: 480
fps: 30
@@ -36,7 +36,6 @@ media:
audioCapture:
enabled: false
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
sampleRate: 48000
channels: 2
@@ -44,7 +43,6 @@ media:
audioPlayback:
enabled: true
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
normalize: true
cameraServo:
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Audio Forward Listener (SRT -> ALSA)
Description=Rover Audio Forward Listener (RTSP/TCP -> ALSA)
After=network-online.target roverd.service
Wants=network-online.target
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Audio Publisher (ALSA -> SRT)
Description=Rover Audio Publisher (ALSA -> RTSP/TCP)
After=network-online.target roverd.service
Wants=network-online.target
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Debian Laptop Video Publisher (V4L2 -> SRT)
Description=Rover Debian Laptop Video Publisher (V4L2 -> RTSP/TCP)
After=network-online.target roverd.service
Wants=network-online.target
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit]
Description=Multi-Roomba rover control agent
After=network-online.target mediamtx.service
After=network-online.target
Wants=network-online.target
[Service]
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Video Publisher (libcamera -> SRT)
Description=Rover Video Publisher (libcamera -> RTSP/TCP)
After=network-online.target roverd.service
Wants=network-online.target
+9 -2
View File
@@ -51,8 +51,15 @@ barcodeGames:
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
# Example: http://media-server.local: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:
# 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/ptzCameraService');
require('./src/services/videoAuthService');
require('./src/services/mediaMtxService');
require('./src/services/videoSocketService');
require('./src/services/roomCameraService');
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"
NEOLINK_BIN="/usr/local/bin/neolink"
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"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.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_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
@@ -259,53 +256,39 @@ if ! verify_google_tts_helper; then
verify_google_tts_helper
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
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
exit 1
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"
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..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
cat > "$MEDIAMTX_SERVICE" <<EOF
[Unit]
Description=mediaMTX WebRTC Server
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
[Unit]
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
[Service]
@@ -316,6 +299,9 @@ Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_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
Restart=on-failure
RestartSec=2
@@ -325,20 +311,17 @@ SuccessExitStatus=130 143
WantedBy=multi-user.target
EOF
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
chmod 644 "$MULTIROVER_SERVICE"
echo "[5/6] Enabling services..."
systemctl daemon-reload
systemctl enable --now mediamtx.service
systemctl enable --now multirover.service
systemctl restart mediamtx.service
systemctl restart multirover.service
echo "[6/6] Done."
echo
echo "Services installed:"
echo " mediamtx.service (WebRTC fan-out)"
echo " multirover.service (Node.js control server)"
echo " multirover.service (Node.js control server with MediaMTX child)"
echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
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) {
const record = roverManager.rovers.get(roverId);
// Rovers listen to the playback stream with a request/read URL. The VIP
// upload path needs to publish into that same stream, so the configured
// nested playback URL is converted to publish mode below.
const configured = record?.meta?.media?.audioPlayback?.forwardUrl;
if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
/*
The server publishes to its own MediaMTX child, so loopback is the stable and correct
route regardless of which hostname a rover uses to reach this machine. RTSP uses the
same path for publish and read; ANNOUNCE/RECORD and DESCRIBE/PLAY distinguish direction.
*/
return `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId + streamSuffix)}`;
}
function resolveForwardPathId(roverId) {
@@ -25,3 +25,8 @@ test('preserves normal audio forwarding for an unmuted verified driver', () => {
const policy = createPolicy();
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;
};
// 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.
proc.once('exit', markExited);
try {
@@ -145,7 +145,13 @@ function createAudioForwardWorkerEngine(deps) {
'-muxpreload',
'0',
'-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,
];
}
+7
View File
@@ -4,7 +4,14 @@
const { httpServer } = require('../../globals/http');
const config = require('../../globals/config');
const logger = require('../../globals/logger').child('httpServer');
const { startMediaMtx } = require('../mediaMtxService');
httpServer.listen(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 isRtspProtocol = protocol === 'rtsp' || protocol.startsWith('rtsp');
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
return res.status(200).end();
@@ -40,6 +41,15 @@ function registerVideoAuthRoute(deps) {
if (action === 'publish' && isSrtLikeProtocol) {
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) {
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);
});