Files
RTSP-Streamer/internal/config/config.go
2026-07-01 18:21:14 -05:00

379 lines
11 KiB
Go

// 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"`
}
// 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"`
// 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 the fully-resolved stream URL.
RTSP string `yaml:"rtsp"`
// Disabled hides the camera from selection without deleting it.
Disabled bool `yaml:"disabled,omitempty"`
}
// 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"`
}
// 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
}
// 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
}
}
// 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)
}
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
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}