Files
RTSP-Streamer/internal/config/config.go
Levi Woodard 81193f524c Fix invisible/flashing clock, map Protect's 8-camera preset, warn on gaps
Three separate faults made cameras "not load" and the clock misbehave.

Clock rendered as nothing, or flashed ~200ms/second. The time was drawn as
an ASS osd-overlay pushed over mpv IPC, but on mpv 0.35 + Mesa/V3D + sway an
OSD overlay is rendered only on the frame where its content *changes*. Every
layer reports success while this happens (mpv returns error:success,
vo-configured is true, and sway reports the window visible at the right
rect), so it looks like a stacking or font bug and is neither. Ruled out:
pushing at 20Hz (identical content is ignored, so it still only redrew when
the second flipped), osd-msg1, show-text, and --pause (mpv stops redrawing
entirely). Fonts were never the issue.

The time is now baked into every frame by a drawtext filter re-reading a
small file the ticker rewrites once a second, with two constraints that cost
real time to find and are pinned by tests:

- The canvas alpha must be > 0. A fully transparent canvas (black@0.0 with
  --alpha=yes) makes the glyphs inherit alpha 0 and the compositor draws
  nothing -- this was the original invisible clock. New clock
  background_opacity (default 0.45) is clamped in config *and* in args() so
  no code path can produce an invisible clock.
- Readahead must be off. drawtext stamps the time when a frame is
  *generated*, so buffering ahead makes the visible clock lag by the
  readahead and swallows text-file updates entirely.

Since the text now arrives through a file, the clock needs no IPC socket:
dropped --input-ipc-server, the ipcPath field, and the stale-socket removal.
assEscape goes with the ASS path.

`views import` produced layouts with holes. viewmap derived the grid from
the slot count alone and ignored Protect's `layout` field, so Protect's
asymmetric 8-camera preset (four 2x2 tiles plus a right column of four 1x1)
landed as 8 tiles in a 3x3 grid -- the bottom-right cell was simply empty
and rendered as a blank rectangle. That preset is now mapped exactly; other
counts keep the uniform GridForSlots fallback rather than guessing at
presets I have not observed. Import also warns when a mapping would leave
empty cells or references a camera missing from the config, so a silent hole
cannot reach the screen again.

Also:

- clock.corner gains bottom-center and top-center (centered horizontally,
  Margin still applies vertically).
- placeClock no longer re-issues `resize set` every tick. Re-asserting
  geometry on a correctly-sized window makes sway send a configure event,
  which makes mpv reallocate buffers and blank for a frame. New
  compositor.Raise re-asserts z-order only, which is all the 2s tick needs;
  geometry is re-placed only when it has actually drifted.
- README documents why the clock is drawn this way, the preset table and how
  to add another from `views dump`, and three troubleshooting entries for
  failure modes that all look like bugs: a blank tile whose mpv is running
  (a stale camera entry -- re-adopting a camera in Protect assigns a new id
  and often a slightly different name, and `discover` never prunes), cameras
  in `cameras:` not being on screen (only the active layout's tiles stream),
  and black bars inside tiles (non-16:9 grid cells; --panscan=1.0 crops to
  fill instead).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYhTnkp7VzJ67THeicgfAQ
2026-07-29 12:17:20 -06:00

538 lines
17 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"`
// 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, bottom-center, top-center. The *-center positions
// center the overlay horizontally and ignore Margin on that axis.
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"`
// BackgroundOpacity is the alpha of the overlay's backing box, 0.0
// (invisible) to 1.0 (solid black). Default 0.45.
//
// It must not be 0: the time is drawn into the canvas by a drawtext filter,
// and on a fully transparent canvas the glyphs inherit the canvas's zero
// alpha, so the compositor draws nothing and the clock silently disappears.
// A small non-zero value gives the text a dark backing that also keeps it
// legible over bright daytime scenes.
BackgroundOpacity float64 `yaml:"background_opacity,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
}
if c.Clock.BackgroundOpacity <= 0 {
// 0 renders an invisible clock (see BackgroundOpacity), so treat
// unset — and any nonsense value — as the default.
c.Clock.BackgroundOpacity = 0.45
}
if c.Clock.BackgroundOpacity > 1 {
c.Clock.BackgroundOpacity = 1
}
}
}
// 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",
"bottom-center", "top-center":
default:
return fmt.Errorf("clock.corner %q must be one of bottom-right, bottom-left, top-right, top-left, bottom-center, top-center", 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)
}