mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
config reshape
This commit is contained in:
@@ -21,7 +21,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
|
||||
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil {
|
||||
log.Fatalf("prepare media env: %v", err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func main() {
|
||||
|
||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
||||
|
||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
|
||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
|
||||
if mediaSupervisor != nil {
|
||||
mediaSupervisor.Start(ctx)
|
||||
}
|
||||
|
||||
+195
-66
@@ -56,13 +56,13 @@ type BatteryConfig struct {
|
||||
}
|
||||
|
||||
type AudioConfig struct {
|
||||
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
||||
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
|
||||
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"`
|
||||
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
|
||||
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
|
||||
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
|
||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
||||
// AudioConfig is only for sounds generated by roverd itself. Live capture
|
||||
// and playback are media-publisher concerns, so those settings live under
|
||||
// MediaConfig where they can be written directly into media.env.
|
||||
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
|
||||
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
|
||||
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
|
||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
||||
}
|
||||
|
||||
type HornConfig struct {
|
||||
@@ -77,21 +77,59 @@ type HornConfig struct {
|
||||
}
|
||||
|
||||
type MediaConfig struct {
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
CameraInverted bool `yaml:"cameraInverted" json:"-"`
|
||||
Manage bool `yaml:"manage"`
|
||||
ManageAudio bool `yaml:"manageAudio"`
|
||||
Service string `yaml:"service"`
|
||||
AudioService string `yaml:"audioService"`
|
||||
HealthURL string `yaml:"healthUrl"`
|
||||
HealthInterval Duration `yaml:"healthInterval"`
|
||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
||||
// PublishPort is shared by the derived video, microphone, and forwarded-audio
|
||||
// SRT 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:"-"`
|
||||
Manage bool `yaml:"manage" json:"manage"`
|
||||
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
|
||||
HealthInterval Duration `yaml:"healthInterval" json:"-"`
|
||||
Video VideoMediaConfig `yaml:"video" json:"video"`
|
||||
AudioCapture AudioCaptureConfig `yaml:"audioCapture" json:"audioCapture"`
|
||||
AudioPlayback AudioPlaybackConfig `yaml:"audioPlayback" json:"audioPlayback"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
Width int `yaml:"width" json:"-"`
|
||||
Height int `yaml:"height" json:"-"`
|
||||
FPS int `yaml:"fps" json:"-"`
|
||||
Bitrate int `yaml:"bitrate" json:"-"`
|
||||
Inverted bool `yaml:"inverted" json:"-"`
|
||||
SensorMode string `yaml:"sensorMode" json:"-"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||
Channels int `yaml:"channels" json:"-"`
|
||||
Bitrate int `yaml:"bitrate" json:"-"`
|
||||
}
|
||||
|
||||
type AudioPlaybackConfig struct {
|
||||
// AudioPlayback describes the reverse stream that VIP users publish into
|
||||
// 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"`
|
||||
Device string `yaml:"device" json:"device,omitempty"`
|
||||
Normalize bool `yaml:"normalize" json:"-"`
|
||||
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
|
||||
}
|
||||
|
||||
type CameraServoConfig struct {
|
||||
@@ -189,9 +227,33 @@ func LoadConfig(path string) (*Config, error) {
|
||||
},
|
||||
Media: MediaConfig{
|
||||
PublishPort: 9000,
|
||||
CameraInverted: true,
|
||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||
VideoBitrate: 2000000,
|
||||
Video: VideoMediaConfig{
|
||||
Enabled: true,
|
||||
Service: "video-publisher.service",
|
||||
Publisher: "pi-libcamera",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
FPS: 30,
|
||||
Bitrate: 2000000,
|
||||
Inverted: true,
|
||||
SensorMode: "1296:972",
|
||||
},
|
||||
AudioCapture: AudioCaptureConfig{
|
||||
Enabled: false,
|
||||
Service: "audio-only-publisher.service",
|
||||
Device: "hw:0,0",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
Bitrate: 510000,
|
||||
},
|
||||
AudioPlayback: AudioPlaybackConfig{
|
||||
Enabled: true,
|
||||
Service: "audio-forward-listener.service",
|
||||
Device: "forward",
|
||||
Normalize: true,
|
||||
NormalizeFilter: "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled",
|
||||
},
|
||||
},
|
||||
CameraServo: CameraServoConfig{
|
||||
Pin: 12,
|
||||
@@ -205,13 +267,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
NudgeDegrees: 2,
|
||||
},
|
||||
Audio: AudioConfig{
|
||||
CaptureEnabled: false,
|
||||
CaptureDevice: "rovermic",
|
||||
PlaybackDevice: "forward",
|
||||
TTSEnabled: false,
|
||||
DefaultEngine: "flite",
|
||||
DefaultVoice: "rms",
|
||||
DefaultPitch: 50,
|
||||
TTSEnabled: false,
|
||||
DefaultEngine: "flite",
|
||||
DefaultVoice: "rms",
|
||||
DefaultPitch: 50,
|
||||
},
|
||||
Horn: HornConfig{
|
||||
Enabled: false,
|
||||
@@ -283,38 +342,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if cfg.BRC.GPIOChip == "" {
|
||||
cfg.BRC.GPIOChip = "gpiochip0"
|
||||
}
|
||||
if cfg.Media.Manage && cfg.Media.Service == "" {
|
||||
return nil, errors.New("media.manage requires media.service")
|
||||
}
|
||||
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
||||
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||
}
|
||||
if cfg.Media.VideoBitrate <= 0 {
|
||||
cfg.Media.VideoBitrate = 3000000
|
||||
}
|
||||
if cfg.Media.PublishPort <= 0 {
|
||||
cfg.Media.PublishPort = 9000
|
||||
}
|
||||
if cfg.Media.PublishURL == "" {
|
||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.Media.PublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioPublishURL == "" {
|
||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioPublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioForwardURL == "" {
|
||||
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioForwardURL = derived
|
||||
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
|
||||
return nil, fmt.Errorf("media: %w", err)
|
||||
}
|
||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||
@@ -375,12 +407,6 @@ func clampFloat(value, min, max float64) float64 {
|
||||
}
|
||||
|
||||
func validateAudioConfig(cfg *AudioConfig) {
|
||||
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
|
||||
cfg.CaptureDevice = "hw:0,0"
|
||||
}
|
||||
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
|
||||
cfg.PlaybackDevice = "forward"
|
||||
}
|
||||
if cfg.DefaultEngine == "" {
|
||||
cfg.DefaultEngine = "flite"
|
||||
}
|
||||
@@ -392,6 +418,109 @@ func validateAudioConfig(cfg *AudioConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) error {
|
||||
/*
|
||||
The nested media config is the source of truth for the publisher scripts,
|
||||
so defaults that used to live in shell are normalized here before any env
|
||||
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.HealthInterval.Duration <= 0 {
|
||||
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||
}
|
||||
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
return fmt.Errorf("video: %w", err)
|
||||
}
|
||||
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
return fmt.Errorf("audioCapture: %w", err)
|
||||
}
|
||||
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil {
|
||||
return fmt.Errorf("audioPlayback: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "video-publisher.service"
|
||||
}
|
||||
if cfg.Publisher == "" {
|
||||
cfg.Publisher = "pi-libcamera"
|
||||
}
|
||||
if cfg.Width <= 0 {
|
||||
cfg.Width = 640
|
||||
}
|
||||
if cfg.Height <= 0 {
|
||||
cfg.Height = 480
|
||||
}
|
||||
if cfg.FPS <= 0 {
|
||||
cfg.FPS = 30
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 2000000
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "audio-only-publisher.service"
|
||||
}
|
||||
if cfg.Device == "" {
|
||||
cfg.Device = "hw:0,0"
|
||||
}
|
||||
if cfg.SampleRate <= 0 {
|
||||
cfg.SampleRate = 48000
|
||||
}
|
||||
if cfg.Channels <= 0 {
|
||||
cfg.Channels = 2
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error {
|
||||
if cfg.Service == "" {
|
||||
cfg.Service = "audio-forward-listener.service"
|
||||
}
|
||||
if cfg.Device == "" {
|
||||
cfg.Device = "forward"
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHornConfig(cfg *HornConfig) {
|
||||
if cfg.Volume <= 0 {
|
||||
cfg.Volume = 0.25
|
||||
|
||||
+38
-37
@@ -7,51 +7,52 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const publisherEnvPath = "/var/lib/roverd/video.env"
|
||||
const publisherEnvPath = "/var/lib/roverd/media.env"
|
||||
|
||||
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||
if media.PublishURL == "" {
|
||||
return fmt.Errorf("media publishUrl missing")
|
||||
func UpdatePublisherEnv(media MediaConfig) error {
|
||||
if media.Video.Enabled && media.Video.PublishURL == "" {
|
||||
return fmt.Errorf("media video publishUrl missing")
|
||||
}
|
||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||
return fmt.Errorf("audio publishUrl missing")
|
||||
if media.AudioCapture.Enabled && media.AudioCapture.PublishURL == "" {
|
||||
return fmt.Errorf("media audioCapture publishUrl missing")
|
||||
}
|
||||
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||
if media.AudioPlayback.Enabled && media.AudioPlayback.ForwardURL == "" {
|
||||
return fmt.Errorf("media audioPlayback forwardUrl missing")
|
||||
}
|
||||
if media.Video.Width <= 0 || media.Video.Height <= 0 || media.Video.FPS <= 0 || media.Video.Bitrate <= 0 {
|
||||
return fmt.Errorf("invalid media video dimensions/bitrate")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
|
||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||
}
|
||||
if media.AudioForwardURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
||||
}
|
||||
if media.VideoWidth > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||
}
|
||||
if media.VideoHeight > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
||||
}
|
||||
if media.VideoFPS > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
||||
}
|
||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
|
||||
audioDevice := audio.CaptureDevice
|
||||
if audioDevice == "" || audioDevice == "rovermic" {
|
||||
audioDevice = "hw:0,0"
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
|
||||
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
|
||||
playbackDevice := audio.PlaybackDevice
|
||||
if playbackDevice == "" {
|
||||
playbackDevice = "forward"
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
|
||||
/*
|
||||
The env file is intentionally verbose: every publisher receives concrete
|
||||
values instead of silently falling back to script-local defaults. That
|
||||
makes roverd.yaml the owner of media behavior while keeping the shell
|
||||
scripts as small pipeline launchers.
|
||||
*/
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_ENABLE=%d\n", boolToInt(media.Video.Enabled))
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_PUBLISHER=%s\n", media.Video.Publisher)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_PUBLISH_URL=%s\n", media.Video.PublishURL)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_DEVICE=%s\n", media.Video.Device)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_WIDTH=%d\n", media.Video.Width)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_HEIGHT=%d\n", media.Video.Height)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_FPS=%d\n", media.Video.FPS)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_BITRATE=%d\n", media.Video.Bitrate)
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_INVERT=%d\n", boolToInt(media.Video.Inverted))
|
||||
fmt.Fprintf(&buf, "ROVERD_VIDEO_SENSOR_MODE=%s\n", media.Video.SensorMode)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_ENABLE=%d\n", boolToInt(media.AudioCapture.Enabled))
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_PUBLISH_URL=%s\n", media.AudioCapture.PublishURL)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_DEVICE=%s\n", media.AudioCapture.Device)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=%d\n", media.AudioCapture.SampleRate)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_CHANNELS=%d\n", media.AudioCapture.Channels)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_CAPTURE_BITRATE=%d\n", media.AudioCapture.Bitrate)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_ENABLE=%d\n", boolToInt(media.AudioPlayback.Enabled))
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_FORWARD_URL=%s\n", media.AudioPlayback.ForwardURL)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_DEVICE=%s\n", media.AudioPlayback.Device)
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_NORMALIZE=%d\n", boolToInt(media.AudioPlayback.Normalize))
|
||||
fmt.Fprintf(&buf, "ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=%s\n", media.AudioPlayback.NormalizeFilter)
|
||||
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,17 +13,16 @@ import (
|
||||
|
||||
type MediaSupervisor struct {
|
||||
cfg MediaConfig
|
||||
audio AudioConfig
|
||||
logger *log.Logger
|
||||
client *http.Client
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
|
||||
if err := UpdatePublisherEnv(cfg, audio); err != nil {
|
||||
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
||||
if err := UpdatePublisherEnv(cfg); err != nil {
|
||||
logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if !cfg.Manage || cfg.Service == "" {
|
||||
if !cfg.Manage {
|
||||
return nil
|
||||
}
|
||||
interval := cfg.HealthInterval.Duration
|
||||
@@ -36,7 +35,6 @@ func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger)
|
||||
}
|
||||
return &MediaSupervisor{
|
||||
cfg: cfg,
|
||||
audio: audio,
|
||||
logger: logger,
|
||||
client: client,
|
||||
checkInterval: interval,
|
||||
@@ -47,7 +45,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
||||
m.logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if m.cfg.HealthURL == "" || m.client == nil {
|
||||
@@ -78,7 +76,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
|
||||
if m == nil {
|
||||
return errors.New("media supervisor disabled")
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
@@ -96,9 +94,9 @@ func (m *MediaSupervisor) checkAndRepair() error {
|
||||
if m.checkHealth(ctx) {
|
||||
return nil
|
||||
}
|
||||
m.logger.Printf("media supervisor: health check failed, restarting %s", m.cfg.Service)
|
||||
m.logger.Printf("media supervisor: health check failed, restarting configured media services")
|
||||
if err := m.runSystemctl(ctx, "restart"); err != nil {
|
||||
return fmt.Errorf("restart mediamtx: %w", err)
|
||||
return fmt.Errorf("restart media services: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -127,16 +125,39 @@ func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error {
|
||||
if m.cfg.Service == "" {
|
||||
return errors.New("no media service configured")
|
||||
services := m.managedServices()
|
||||
if len(services) == 0 {
|
||||
return errors.New("no media services configured")
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output))
|
||||
for _, service := range services {
|
||||
cmd := exec.CommandContext(runCtx, "systemctl", action, service)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s %s: %w (%s)", action, service, err, string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) managedServices() []string {
|
||||
/*
|
||||
Only enabled streams are service-managed. This matters for audio capture:
|
||||
the service may be installed everywhere, but a rover with capture disabled
|
||||
should not have remote media commands starting a publisher that the config
|
||||
says should stay off.
|
||||
*/
|
||||
services := []string{}
|
||||
if m.cfg.Video.Enabled && m.cfg.Video.Service != "" {
|
||||
services = append(services, m.cfg.Video.Service)
|
||||
}
|
||||
if m.cfg.AudioCapture.Enabled && m.cfg.AudioCapture.Service != "" {
|
||||
services = append(services, m.cfg.AudioCapture.Service)
|
||||
}
|
||||
if m.cfg.AudioPlayback.Enabled && m.cfg.AudioPlayback.Service != "" {
|
||||
services = append(services, m.cfg.AudioPlayback.Service)
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
@@ -17,16 +17,36 @@ battery:
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
publishPort: 9000
|
||||
# Default assumes camera is mounted upside down; set false for upright mounts.
|
||||
cameraInverted: true
|
||||
videoBitrate: 2000000
|
||||
manage: true
|
||||
service: video-publisher.service
|
||||
healthUrl: ""
|
||||
healthInterval: 30s
|
||||
video:
|
||||
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
|
||||
bitrate: 2000000
|
||||
# Default assumes camera is mounted upside down; set false for upright mounts.
|
||||
inverted: true
|
||||
sensorMode: 1296:972
|
||||
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
|
||||
bitrate: 510000
|
||||
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:
|
||||
enabled: false
|
||||
pin: 12
|
||||
|
||||
+10
-1
@@ -17,9 +17,18 @@ battery:
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
video:
|
||||
enabled: true
|
||||
service: video-publisher.service
|
||||
publisher: pi-libcamera
|
||||
audioCapture:
|
||||
enabled: false
|
||||
service: audio-only-publisher.service
|
||||
audioPlayback:
|
||||
enabled: true
|
||||
service: audio-forward-listener.service
|
||||
cameraServo:
|
||||
enabled: false
|
||||
pin: 19
|
||||
|
||||
Reference in New Issue
Block a user