mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
switched rover <-> server streams to rtsptcp, and moved mediamtx to be a server owned and configured child process!
This commit is contained in:
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
||||
exit 1
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -123,13 +122,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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# These publishers contain hardware-facing infinite retry loops, so executing them in a unit
|
||||
# test would require unsafe process-group traps and fake camera/ALSA devices. Pin the small
|
||||
# transport boundary directly instead: every publisher must request RTSP/TCP and none may
|
||||
# reintroduce the high-latency MPEG-TS muxer.
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
assert_rtsp_tcp() {
|
||||
local file="$1"
|
||||
if ! grep -q -- '-f rtsp' "$file"; then
|
||||
echo "Missing RTSP muxer in $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-rtsp_transport tcp' "$file"; then
|
||||
echo "Missing RTSP/TCP pin in $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-f mpegts' "$file"; then
|
||||
echo "Unexpected MPEG-TS muxer in $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_rtsp_tcp "$SCRIPT_DIR/video-publisher.sh"
|
||||
assert_rtsp_tcp "$SCRIPT_DIR/debian-laptop-video-publisher.sh"
|
||||
assert_rtsp_tcp "$SCRIPT_DIR/audio-only-publisher.sh"
|
||||
|
||||
# The speaker path reads rather than publishes, so it has no output muxer. It must still pin
|
||||
# RTSP/TCP before its input URL to match the server's TCP-only listener.
|
||||
if ! grep -q -- '-rtsp_transport tcp' "$SCRIPT_DIR/audio-forward-listener.sh"; then
|
||||
echo "Missing RTSP/TCP input pin in audio-forward-listener.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Media publisher transport checks passed"
|
||||
@@ -110,6 +110,10 @@ else
|
||||
fi
|
||||
|
||||
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}"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}$`)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package roverd
|
||||
|
||||
// These tests pin the network-agnostic RTSP contract. A rover provides its server URL and name
|
||||
// once; all three media paths must then resolve to distinct, safely escaped MediaMTX paths.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMediaURLsDeriveFromServerURLAndRoverName(t *testing.T) {
|
||||
cfg := MediaConfig{
|
||||
Video: VideoMediaConfig{Enabled: true},
|
||||
AudioCapture: AudioCaptureConfig{Enabled: true},
|
||||
AudioPlayback: AudioPlaybackConfig{Enabled: true},
|
||||
}
|
||||
if err := validateMediaConfig(&cfg, "ws://control-server.local:8080/rover", "rover one"); err != nil {
|
||||
t.Fatalf("validate media config: %v", err)
|
||||
}
|
||||
|
||||
wants := map[string]string{
|
||||
"video": "rtsp://control-server.local:8554/rover%20one",
|
||||
"mic": "rtsp://control-server.local:8554/rover%20one-audio",
|
||||
"speaker": "rtsp://control-server.local:8554/rover%20one-fwd",
|
||||
}
|
||||
got := map[string]string{
|
||||
"video": cfg.Video.PublishURL,
|
||||
"mic": cfg.AudioCapture.PublishURL,
|
||||
"speaker": cfg.AudioPlayback.ForwardURL,
|
||||
}
|
||||
for name, want := range wants {
|
||||
if got[name] != want {
|
||||
t.Errorf("%s URL: got %q, want %q", name, got[name], want)
|
||||
}
|
||||
}
|
||||
if cfg.RTSPPort != 8554 {
|
||||
t.Fatalf("RTSP port: got %d, want 8554", cfg.RTSPPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitMediaPortAppliesToEveryRTSPPath(t *testing.T) {
|
||||
cfg := MediaConfig{
|
||||
RTSPPort: 10554,
|
||||
Video: VideoMediaConfig{Enabled: true},
|
||||
AudioCapture: AudioCaptureConfig{Enabled: true},
|
||||
AudioPlayback: AudioPlaybackConfig{Enabled: true},
|
||||
}
|
||||
if err := validateMediaConfig(&cfg, "ws://media.example/rover", "r1"); err != nil {
|
||||
t.Fatalf("validate media config: %v", err)
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
|
||||
} {
|
||||
if !strings.Contains(value, ":10554/") {
|
||||
t.Errorf("%s URL did not use configured port: %q", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyExplicitSRTURLsCannotKeepAnUpdatedRoverOnTheOldTransport(t *testing.T) {
|
||||
/*
|
||||
Deployed rover configs can still contain these former fields. Validation must replace
|
||||
them unconditionally so updating roverd is sufficient to move the whole media path.
|
||||
*/
|
||||
cfg := MediaConfig{
|
||||
Video: VideoMediaConfig{Enabled: true, PublishURL: "srt://old/video"},
|
||||
AudioCapture: AudioCaptureConfig{Enabled: true, PublishURL: "srt://old/audio"},
|
||||
AudioPlayback: AudioPlaybackConfig{Enabled: true, ForwardURL: "srt://old/forward"},
|
||||
}
|
||||
if err := validateMediaConfig(&cfg, "ws://new-server.local:8080/rover", "r1"); err != nil {
|
||||
t.Fatalf("validate media config: %v", err)
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
|
||||
} {
|
||||
if !strings.HasPrefix(value, "rtsp://new-server.local:8554/") {
|
||||
t.Errorf("%s retained an old transport URL: %q", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ battery:
|
||||
maxWheelSpeed: 350
|
||||
|
||||
media:
|
||||
publishPort: 9000
|
||||
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
|
||||
rtspPort: 8554
|
||||
manage: true
|
||||
healthUrl: ""
|
||||
healthInterval: 30s
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
Reference in New Issue
Block a user