package roverd import ( "errors" "fmt" "net" "net/url" "os" "regexp" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) type SerialConfig struct { Device string `yaml:"device"` Baud int `yaml:"baud"` } type Duration struct { time.Duration } func (d *Duration) UnmarshalYAML(value *yaml.Node) error { var raw string if err := value.Decode(&raw); err != nil { return err } parsed, err := time.ParseDuration(raw) if err != nil { return err } d.Duration = parsed return nil } func (d Duration) MarshalYAML() (interface{}, error) { return d.Duration.String(), nil } type BRCConfig struct { GPIOPin int `yaml:"gpioPin"` GPIOChip string `yaml:"gpioChip"` PulseEvery Duration `yaml:"pulseEvery"` PulseWidth Duration `yaml:"pulseWidth"` } func (b BRCConfig) Enabled() bool { return b.GPIOPin >= 0 } type BatteryConfig struct { Full int `yaml:"full"` Warn int `yaml:"warn"` Urgent int `yaml:"urgent"` } type AudioConfig struct { // 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 { Enabled bool `yaml:"enabled" json:"enabled"` Volume float64 `yaml:"volume" json:"-"` SampleRate int `yaml:"sampleRate" json:"-"` Channels int `yaml:"channels" json:"-"` Device string `yaml:"device" json:"-"` SineGain float64 `yaml:"sineGain" json:"-"` SawGain float64 `yaml:"sawGain" json:"-"` MaxDuration Duration `yaml:"maxDuration" json:"-"` } type MediaConfig struct { // 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. RTSPPort int `yaml:"rtspPort" 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 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:"-"` 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 "-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 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:"-"` 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 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:"-"` } type CameraServoConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` Pin int `yaml:"pin" json:"pin"` FreqHz int `yaml:"freqHz" json:"freqHz"` CycleLen int `yaml:"cycleLen" json:"cycleLen"` MinPulseUs int `yaml:"minPulseUs" json:"minPulseUs"` MaxPulseUs int `yaml:"maxPulseUs" json:"maxPulseUs"` MinAngle float64 `yaml:"minAngle" json:"minAngle"` MaxAngle float64 `yaml:"maxAngle" json:"maxAngle"` HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"` NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"` AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"` Invert bool `yaml:"invert" json:"invert"` } type GPIOToggleConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` GPIOPin int `yaml:"gpioPin" json:"gpioPin"` GPIOChip string `yaml:"gpioChip" json:"gpioChip"` InitialOn bool `yaml:"initialOn" json:"initialOn"` ActiveLow bool `yaml:"activeLow" json:"activeLow"` } func (g GPIOToggleConfig) LogicalToGPIO(on bool) int { // activeLow is the single hardware-inversion point for GPIO toggles. The // rest of the daemon, server, and web UI can use normal logical on/off // semantics without knowing whether the driver is active-high or active-low. if on == g.ActiveLow { return 0 } return 1 } type AutoSideBrushConfig struct { Enabled bool `yaml:"enabled"` Speed int `yaml:"speed"` } type PrivateConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` Safety PrivateSafetyConfig `yaml:"safety" json:"safety"` } type PrivateSafetyConfig struct { SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"` SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"` HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"` OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"` HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"` BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"` BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"` CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"` CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"` CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"` VirtualWallEnabled bool `yaml:"virtualWallEnabled" json:"virtualWallEnabled"` VirtualWallBackoffSpeed int `yaml:"virtualWallBackoffSpeed" json:"virtualWallBackoffSpeed"` VirtualWallBackoffMs int `yaml:"virtualWallBackoffMs" json:"virtualWallBackoffMs"` TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"` } type Config struct { Name string `yaml:"name"` Description string `yaml:"description" json:"description,omitempty"` Color string `yaml:"color" json:"color,omitempty"` ServerURL string `yaml:"serverUrl"` Serial SerialConfig `yaml:"serial"` BRC BRCConfig `yaml:"brc"` Battery BatteryConfig `yaml:"battery"` MaxWheelMMs int `yaml:"maxWheelSpeed"` Media MediaConfig `yaml:"media"` CameraServo CameraServoConfig `yaml:"cameraServo"` Audio AudioConfig `yaml:"audio"` Horn HornConfig `yaml:"horn"` Headlight GPIOToggleConfig `yaml:"headlight" json:"headlight"` Laser GPIOToggleConfig `yaml:"laser" json:"laser"` AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"` Private PrivateConfig `yaml:"private" json:"private"` } func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } cfg := Config{ MaxWheelMMs: 500, BRC: BRCConfig{ GPIOPin: 4, GPIOChip: "gpiochip0", PulseEvery: Duration{ Duration: time.Minute, }, PulseWidth: Duration{ Duration: time.Second, }, }, Media: MediaConfig{ RTSPPort: 8554, HealthInterval: Duration{Duration: 30 * time.Second}, 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, FreqHz: 50, CycleLen: 20000, MinPulseUs: 900, MaxPulseUs: 2100, MinAngle: -15, MaxAngle: 30, HomeAngle: 0, NudgeDegrees: 2, }, Audio: AudioConfig{ TTSEnabled: false, DefaultEngine: "flite", DefaultVoice: "rms", DefaultPitch: 50, }, Horn: HornConfig{ Enabled: false, Volume: 0.25, SampleRate: 48000, Channels: 1, SineGain: 1.0, SawGain: 0.7, MaxDuration: Duration{Duration: 10000 * time.Millisecond}, }, Headlight: GPIOToggleConfig{ Enabled: true, GPIOPin: 22, GPIOChip: "gpiochip0", InitialOn: false, ActiveLow: true, }, Laser: GPIOToggleConfig{ Enabled: false, GPIOPin: 27, GPIOChip: "gpiochip0", InitialOn: false, ActiveLow: false, }, AutoSideBrush: AutoSideBrushConfig{ Enabled: true, Speed: 20, }, Private: PrivateConfig{ Enabled: false, Safety: PrivateSafetyConfig{ SpeedLimitEnabled: false, SpeedLimitMaxWheelMMs: 250, HardOvercurrentEnabled: false, OvercurrentStopMs: 300, HardBumpEnabled: false, BumpBackoffSpeed: 250, BumpBackoffMs: 350, CliffEnabled: false, CliffBackoffSpeed: 250, CliffBackoffMs: 500, VirtualWallEnabled: true, VirtualWallBackoffSpeed: 250, VirtualWallBackoffMs: 500, TriggerCooldownMs: 800, }, }, } if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } if cfg.Name == "" { return nil, errors.New("missing name") } normalizedColor, err := normalizeHexColor(cfg.Color) if err != nil { return nil, err } cfg.Color = normalizedColor if cfg.ServerURL == "" { return nil, errors.New("missing serverUrl") } if cfg.Serial.Device == "" || cfg.Serial.Baud == 0 { return nil, errors.New("serial device/baud required") } if cfg.Battery.Full == 0 { return nil, errors.New("battery thresholds required") } if cfg.MaxWheelMMs <= 0 || cfg.MaxWheelMMs > 500 { return nil, fmt.Errorf("maxWheelSpeed must be 1-500, got %d", cfg.MaxWheelMMs) } if cfg.BRC.GPIOChip == "" { cfg.BRC.GPIOChip = "gpiochip0" } 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) } if err := validateServoConfig(&cfg.CameraServo); err != nil { return nil, fmt.Errorf("cameraServo: %w", err) } if err := validateGPIOToggleConfig(&cfg.Headlight); err != nil { return nil, fmt.Errorf("headlight: %w", err) } if err := validateGPIOToggleConfig(&cfg.Laser); err != nil { return nil, fmt.Errorf("laser: %w", err) } validateAudioConfig(&cfg.Audio) validateHornConfig(&cfg.Horn) validateAutoSideBrushConfig(&cfg.AutoSideBrush) return &cfg, nil } func validateServoConfig(cfg *CameraServoConfig) error { if !cfg.Enabled { return nil } if cfg.Pin <= 0 { return errors.New("pin must be > 0") } if cfg.FreqHz <= 0 { return errors.New("freqHz must be > 0") } if cfg.CycleLen <= 0 { return errors.New("cycleLen must be > 0") } if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 { return errors.New("minPulseUs/maxPulseUs invalid") } if cfg.MinPulseUs == cfg.MaxPulseUs { return errors.New("minPulseUs/maxPulseUs cannot be equal") } if cfg.MinPulseUs > cfg.MaxPulseUs { cfg.MinPulseUs, cfg.MaxPulseUs = cfg.MaxPulseUs, cfg.MinPulseUs cfg.Invert = !cfg.Invert } if cfg.MinAngle >= cfg.MaxAngle { return errors.New("minAngle must be less than maxAngle") } cfg.HomeAngle = clampFloat(cfg.HomeAngle, cfg.MinAngle, cfg.MaxAngle) if cfg.NudgeDegrees <= 0 { cfg.NudgeDegrees = 2 } return nil } func clampFloat(value, min, max float64) float64 { if value < min { return min } if value > max { return max } return value } func validateAudioConfig(cfg *AudioConfig) { if cfg.DefaultEngine == "" { cfg.DefaultEngine = "flite" } if cfg.DefaultVoice == "" { cfg.DefaultVoice = "rms" } if cfg.DefaultPitch <= 0 { cfg.DefaultPitch = 50 } } 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.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.RTSPPort); err != nil { return fmt.Errorf("video: %w", err) } 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.RTSPPort); err != nil { return fmt.Errorf("audioPlayback: %w", err) } return nil } func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, rtspPort int) error { if cfg.Service == "" { cfg.Service = "video-publisher.service" } if cfg.Publisher == "" { cfg.Publisher = "pi-libcamera" } // V4L2 input formats are consumed by ffmpeg as lowercase names such as // mjpeg or yuyv422. Normalizing here keeps the publisher script simple and // makes hand-edited Debian laptop configs less sensitive to capitalization. cfg.InputFormat = strings.ToLower(strings.TrimSpace(cfg.InputFormat)) 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" } /* 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, rtspPort 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 } 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, rtspPort 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" } derived, err := deriveReadURL(serverURL, roverName+"-fwd", rtspPort) 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 } if cfg.Volume > 1 { cfg.Volume = 1 } if cfg.SampleRate <= 0 { cfg.SampleRate = 48000 } if cfg.Channels <= 0 { cfg.Channels = 1 } if cfg.SineGain <= 0 { cfg.SineGain = 1.0 } if cfg.SawGain <= 0 { cfg.SawGain = 0.7 } if cfg.MaxDuration.Duration <= 0 { cfg.MaxDuration = Duration{Duration: 1200 * time.Millisecond} } } func validateGPIOToggleConfig(cfg *GPIOToggleConfig) error { if !cfg.Enabled { return nil } if cfg.GPIOPin <= 0 { return errors.New("gpioPin must be > 0") } if cfg.GPIOChip == "" { cfg.GPIOChip = "gpiochip0" } return nil } func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) { if cfg.Speed == 0 { return } cfg.Speed = clampInt(cfg.Speed, -127, 127) } func derivePublishURL(serverURL, streamName string, port int) (string, error) { return deriveRTSPURL(serverURL, streamName, port) } func deriveReadURL(serverURL, streamName string, port int) (string, error) { return deriveRTSPURL(serverURL, streamName, port) } func deriveRTSPURL(serverURL, streamName string, port int) (string, error) { if streamName == "" { return "", errors.New("missing stream name for publishUrl") } parsed, err := url.Parse(serverURL) if err != nil { return "", err } host := parsed.Hostname() if host == "" { return "", errors.New("serverUrl missing host") } if port <= 0 { 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("rtsp://%s/%s", net.JoinHostPort(host, strconv.Itoa(port)), escaped), nil } var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) func normalizeHexColor(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "", nil } if !hexColorRe.MatchString(trimmed) { return "", fmt.Errorf("color must be #RRGGBB, got %q", raw) } return strings.ToUpper(trimmed), nil }