// Package config defines the on-disk configuration for rtsp-streamer and // handles loading, validation, and atomic saving. The config is a single // YAML file that is safe to hand-edit or to mutate via the TUI. package config import ( "fmt" "os" "path/filepath" "strconv" "strings" "gopkg.in/yaml.v3" ) // Config is the root document persisted to disk. type Config struct { // Controller describes how to reach the UniFi Protect controller for // camera discovery. Optional if you only use manually-added cameras. Controller Controller `yaml:"controller"` // Display pins the output resolution used for grid geometry math. When // zero, the daemon asks the compositor for the connected output's mode. Display Display `yaml:"display"` // Player holds mpv tuning shared by every stream. Player Player `yaml:"player"` // Cameras is the discovered/known camera catalog. Populated by // `rtsp-streamer discover` or edited by hand. Layouts reference cameras // by Name. Cameras []Camera `yaml:"cameras"` // Layouts are named preset grids. Layouts []Layout `yaml:"layouts"` // ActiveLayout is the name of the layout the daemon renders. ActiveLayout string `yaml:"active_layout"` // ViewRefreshSeconds, when > 0, makes the daemon periodically re-sync the // active layout from its linked UniFi Protect live view (ProtectView). // Requires controller credentials available to the daemon. 0 = off. ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty"` // Clock overlays a live clock in a screen corner. Clock Clock `yaml:"clock,omitempty"` } // Clock configures the on-screen clock overlay: a small always-on-top window // showing the current local time, drawn as outlined white text over the video // so it stays legible on both bright (day) and dark (night) scenes. type Clock struct { // Enabled turns the overlay on. Enabled bool `yaml:"enabled"` // Timezone is an IANA name (e.g. "America/Denver"); "Local" or empty uses // the system timezone. DST is handled automatically. Timezone string `yaml:"timezone,omitempty"` // Format is a Go time layout. Default "15:04:05" (24-hour with seconds). // Examples: "3:04:05 PM", "Mon Jan 2 15:04". Format string `yaml:"format,omitempty"` // Corner places the overlay: bottom-right (default), bottom-left, // top-right, top-left. Corner string `yaml:"corner,omitempty"` // FontSize is the glyph height in pixels (default 44). FontSize int `yaml:"font_size,omitempty"` // Width/Height are the overlay window size in pixels (defaults 300x72). Width int `yaml:"width,omitempty"` Height int `yaml:"height,omitempty"` // Margin is the gap from the screen edges in pixels (default 24). Margin int `yaml:"margin,omitempty"` } // Controller holds UniFi Protect connection details. type Controller struct { Host string `yaml:"host"` // hostname or IP of the UniFi OS console Username string `yaml:"username"` // local Protect user with camera access // Password is read here only if PasswordEnv is empty. Prefer PasswordEnv // so secrets stay out of the committed config file. Password string `yaml:"password,omitempty"` PasswordEnv string `yaml:"password_env,omitempty"` // VerifyTLS toggles certificate verification. UniFi consoles ship a // self-signed cert by default, so this is false unless you install a // trusted cert. VerifyTLS bool `yaml:"verify_tls"` // RTSPPort is the Protect RTSPS port (7441 on current firmware). RTSPPort int `yaml:"rtsp_port,omitempty"` } // ResolvePassword returns the effective password, preferring the env var. func (c Controller) ResolvePassword() string { if c.PasswordEnv != "" { if v := os.Getenv(c.PasswordEnv); v != "" { return v } } return c.Password } // Display pins the render resolution. type Display struct { Width int `yaml:"width,omitempty"` Height int `yaml:"height,omitempty"` } // Player is shared mpv configuration. type Player struct { // HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m", // "drm", "no"). "auto-safe" is a good default on the Pi 4. HWDec string `yaml:"hwdec"` // Profile applies an mpv profile; "low-latency" trims buffering for live // feeds. Empty disables it. Profile string `yaml:"profile"` // ExtraArgs are appended verbatim to every mpv invocation. ExtraArgs []string `yaml:"extra_args,omitempty"` // MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims // render/scale load; the bigger decode lever is using substreams. MaxFPS int `yaml:"max_fps,omitempty"` // Audio enables stream sound. Off by default: a wall of simultaneous // feeds is unwatchable with sound, and skipping the audio decoder saves // CPU per stream. Audio bool `yaml:"audio,omitempty"` // ResyncSeconds, when > 0, makes the daemon reconnect each stream to the // live edge on this interval (staggered across tiles). Live RTSP can't be // seeked, so latency that slowly accumulates when the Pi decodes a hair // behind real-time is only cleared by reopening the stream. Each tile is // resynced about once per interval; e.g. 600 keeps drift well under a few // seconds. 0 = off. ResyncSeconds int `yaml:"resync_seconds,omitempty"` // RestartBackoffSeconds is how long to wait before relaunching a stream // that exited or stalled. RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"` } // Camera is one known RTSP source. type Camera struct { // ID is the UniFi Protect camera id, when discovered. Blank for manual // entries. ID string `yaml:"id,omitempty"` // Name is the human label and the key layouts reference. Must be unique. Name string `yaml:"name"` // RTSP is a single fully-resolved stream URL. Kept for backward // compatibility and manual entries; Streams takes precedence when present. RTSP string `yaml:"rtsp,omitempty"` // Streams maps a quality ("high"|"medium"|"low") to its stream URL, so a // tile can choose per-tile which to pull. Populated by discovery. Streams map[string]string `yaml:"streams,omitempty"` // Disabled hides the camera from selection without deleting it. Disabled bool `yaml:"disabled,omitempty"` } // Qualities in preference order, high to low. var Qualities = []string{"high", "medium", "low"} // StreamURL returns the URL for the requested quality, falling back sensibly: // the exact quality, then any lower quality, then any stream at all, then the // legacy single RTSP field. func (c Camera) StreamURL(quality string) string { if len(c.Streams) > 0 { if quality != "" { if u := c.Streams[quality]; u != "" { return u } } // Fall back down the preference list from the requested quality. start := 0 for i, q := range Qualities { if q == quality { start = i break } } for _, q := range Qualities[start:] { if u := c.Streams[q]; u != "" { return u } } for _, q := range Qualities { if u := c.Streams[q]; u != "" { return u } } } return c.RTSP } // AvailableQualities lists the qualities this camera actually has, high to low. func (c Camera) AvailableQualities() []string { var out []string for _, q := range Qualities { if c.Streams[q] != "" { out = append(out, q) } } return out } // MaxTiles caps how many simultaneous streams a layout may show. Decoding // more than this on a Pi 4 is impractical even with substreams. const MaxTiles = 16 // Layout is a named arrangement on a base grid. Cameras are placed as Tiles // that may span multiple grid cells (a big main view plus small side tiles, // security-wall style). The older Slots form (one camera per cell, row-major) // is still accepted and is transparently upgraded to tiles. type Layout struct { Name string `yaml:"name"` // Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and // sized in these cells. Grid string `yaml:"grid"` // Tiles is the placement model. Preferred over Slots. Tiles []Tile `yaml:"tiles,omitempty"` // Slots is the legacy one-camera-per-cell model (row-major). Kept for // backward compatibility; EffectiveTiles converts it to tiles. Slots []string `yaml:"slots,omitempty"` // ProtectView, when set, is the name of the UniFi Protect live view this // layout mirrors. `views import` sets it; the daemon re-syncs it on a timer // when view_refresh_seconds > 0 and controller creds are available. ProtectView string `yaml:"protect_view,omitempty"` } // Tile places one camera at a rectangular region of the base grid. type Tile struct { Camera string `yaml:"camera"` Col int `yaml:"col"` Row int `yaml:"row"` ColSpan int `yaml:"colspan,omitempty"` // defaults to 1 RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1 // Quality selects which stream to pull for this tile: "high"|"medium"| // "low". Empty means the camera's best available (see Camera.StreamURL). Quality string `yaml:"quality,omitempty"` } // Span returns the tile's spans with zero values normalized to 1. func (t Tile) Span() (colspan, rowspan int) { colspan, rowspan = t.ColSpan, t.RowSpan if colspan < 1 { colspan = 1 } if rowspan < 1 { rowspan = 1 } return colspan, rowspan } // EffectiveTiles returns the layout's tiles, normalizing spans and upgrading a // legacy Slots list to 1x1 tiles when Tiles is empty. func (l Layout) EffectiveTiles() []Tile { if len(l.Tiles) > 0 { out := make([]Tile, len(l.Tiles)) for i, t := range l.Tiles { cs, rs := t.Span() t.ColSpan, t.RowSpan = cs, rs out[i] = t } return out } cols, _, err := l.Dimensions() if err != nil || cols == 0 { return nil } var out []Tile for i, cam := range l.Slots { if cam == "" { continue } out = append(out, Tile{Camera: cam, Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1}) } return out } // Dimensions parses Grid into cols, rows. func (l Layout) Dimensions() (cols, rows int, err error) { parts := strings.SplitN(strings.ToLower(strings.TrimSpace(l.Grid)), "x", 2) if len(parts) != 2 { return 0, 0, fmt.Errorf("layout %q: grid %q must look like COLSxROWS", l.Name, l.Grid) } cols, err = strconv.Atoi(strings.TrimSpace(parts[0])) if err != nil || cols < 1 { return 0, 0, fmt.Errorf("layout %q: bad column count in grid %q", l.Name, l.Grid) } rows, err = strconv.Atoi(strings.TrimSpace(parts[1])) if err != nil || rows < 1 { return 0, 0, fmt.Errorf("layout %q: bad row count in grid %q", l.Name, l.Grid) } return cols, rows, nil } // Capacity is the number of cells in the grid. func (l Layout) Capacity() int { cols, rows, err := l.Dimensions() if err != nil { return 0 } return cols * rows } // CameraByName returns the named camera, or nil if absent. func (c *Config) CameraByName(name string) *Camera { for i := range c.Cameras { if c.Cameras[i].Name == name { return &c.Cameras[i] } } return nil } // LayoutByName returns the named layout, or nil if absent. func (c *Config) LayoutByName(name string) *Layout { for i := range c.Layouts { if c.Layouts[i].Name == name { return &c.Layouts[i] } } return nil } // Active returns the currently selected layout, or nil. func (c *Config) Active() *Layout { if c.ActiveLayout == "" { return nil } return c.LayoutByName(c.ActiveLayout) } // Defaults fills in sensible zero-value replacements. Called after load. func (c *Config) Defaults() { if c.Controller.RTSPPort == 0 { c.Controller.RTSPPort = 7441 } if c.Player.HWDec == "" { c.Player.HWDec = "auto-safe" } if c.Player.RestartBackoffSeconds == 0 { c.Player.RestartBackoffSeconds = 3 } if c.Clock.Enabled { if c.Clock.Timezone == "" { c.Clock.Timezone = "Local" } if c.Clock.Format == "" { c.Clock.Format = "15:04:05" } if c.Clock.Corner == "" { c.Clock.Corner = "bottom-right" } if c.Clock.FontSize == 0 { c.Clock.FontSize = 44 } if c.Clock.Width == 0 { c.Clock.Width = 300 } if c.Clock.Height == 0 { c.Clock.Height = 72 } if c.Clock.Margin == 0 { c.Clock.Margin = 24 } } } // Validate checks referential integrity and returns the first problem found. func (c *Config) Validate() error { seen := map[string]bool{} for _, cam := range c.Cameras { if cam.Name == "" { return fmt.Errorf("a camera is missing a name") } if seen[cam.Name] { return fmt.Errorf("duplicate camera name %q", cam.Name) } seen[cam.Name] = true } layoutNames := map[string]bool{} for _, l := range c.Layouts { if l.Name == "" { return fmt.Errorf("a layout is missing a name") } if layoutNames[l.Name] { return fmt.Errorf("duplicate layout name %q", l.Name) } layoutNames[l.Name] = true cols, rows, err := l.Dimensions() if err != nil { return err } if len(l.Tiles) > 0 { if err := validateTiles(l, cols, rows, seen); err != nil { return err } } else { if len(l.Slots) > cols*rows { return fmt.Errorf("layout %q: %d slots exceed grid capacity %d", l.Name, len(l.Slots), cols*rows) } for _, slot := range l.Slots { if slot != "" && !seen[slot] { return fmt.Errorf("layout %q references unknown camera %q", l.Name, slot) } } } } if c.ActiveLayout != "" && !layoutNames[c.ActiveLayout] { return fmt.Errorf("active_layout %q is not a defined layout", c.ActiveLayout) } if c.Clock.Enabled && c.Clock.Corner != "" { switch c.Clock.Corner { case "bottom-right", "bottom-left", "top-right", "top-left": default: return fmt.Errorf("clock.corner %q must be one of bottom-right, bottom-left, top-right, top-left", c.Clock.Corner) } } return nil } // validateTiles checks a tile-based layout: in-bounds, no overlap, known // cameras, and within the MaxTiles cap. func validateTiles(l Layout, cols, rows int, knownCameras map[string]bool) error { if len(l.Tiles) > MaxTiles { return fmt.Errorf("layout %q: %d tiles exceed the %d-camera limit", l.Name, len(l.Tiles), MaxTiles) } occupied := make([]bool, cols*rows) for _, t := range l.Tiles { cs, rs := t.Span() if t.Col < 0 || t.Row < 0 || t.Col+cs > cols || t.Row+rs > rows { return fmt.Errorf("layout %q: tile %q at (%d,%d)+%dx%d falls outside the %dx%d grid", l.Name, t.Camera, t.Col, t.Row, cs, rs, cols, rows) } if t.Camera != "" && !knownCameras[t.Camera] { return fmt.Errorf("layout %q references unknown camera %q", l.Name, t.Camera) } for r := t.Row; r < t.Row+rs; r++ { for cc := t.Col; cc < t.Col+cs; cc++ { idx := r*cols + cc if occupied[idx] { return fmt.Errorf("layout %q: tiles overlap at cell (col %d, row %d)", l.Name, cc, r) } occupied[idx] = true } } } return nil } // DefaultPath returns the XDG config path, honoring $RTSP_STREAMER_CONFIG. func DefaultPath() string { if p := os.Getenv("RTSP_STREAMER_CONFIG"); p != "" { return p } base := os.Getenv("XDG_CONFIG_HOME") if base == "" { if home, err := os.UserHomeDir(); err == nil { base = filepath.Join(home, ".config") } } return filepath.Join(base, "rtsp-streamer", "config.yaml") } // Load reads and validates the config at path. A missing file yields a // zero-value config with defaults applied (not an error), so first-run tools // can start from an empty state. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { c := &Config{} c.Defaults() return c, nil } return nil, err } var c Config if err := yaml.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("parsing %s: %w", path, err) } c.Defaults() if err := c.Validate(); err != nil { return nil, fmt.Errorf("invalid config %s: %w", path, err) } return &c, nil } // Save writes the config atomically (temp file + rename) so a crash mid-write // never truncates the live config. func Save(path string, c *Config) error { if err := c.Validate(); err != nil { return fmt.Errorf("refusing to save invalid config: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := yaml.Marshal(c) if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.yaml.tmp") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) // no-op if rename succeeded if _, err := tmp.Write(data); err != nil { tmp.Close() return err } // Flush to stable storage before the rename: on SD cards a power cut // between rename and writeback can otherwise leave an empty config. if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpName, path) }