mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
621 lines
19 KiB
Go
621 lines
19 KiB
Go
package roverd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"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 {
|
|
// 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"`
|
|
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 "<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 {
|
|
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{
|
|
PublishPort: 9000,
|
|
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.PublishPort <= 0 {
|
|
cfg.Media.PublishPort = 9000
|
|
}
|
|
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.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"
|
|
}
|
|
// 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"
|
|
}
|
|
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
|
|
}
|
|
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 deriveSRTURL(serverURL, streamName, port, "publish")
|
|
}
|
|
|
|
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
|
return deriveSRTURL(serverURL, streamName, port, "request")
|
|
}
|
|
|
|
func deriveSRTURL(serverURL, streamName string, port int, mode string) (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
|
|
}
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return "", errors.New("serverUrl missing host")
|
|
}
|
|
if port <= 0 {
|
|
port = 9000
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|