views ls/dump/import copy a Protect live view (cameras + grid) into a layout; grid inferred from slot count (asymmetric presets TBD, see views dump). Imported layouts link back via protect_view. Daemon re-syncs the active linked view every view_refresh_seconds (0= off), so Protect-side edits appear on the wall. Needs the controller password at runtime; off by default keeps the wall credential-free. Shared viewmap package used by CLI and daemon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
444 lines
13 KiB
Go
444 lines
13 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"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|