adding to git.i0t.app
This commit is contained in:
378
internal/config/config.go
Normal file
378
internal/config/config.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user