The TUI is now a TypeScript/opentui app in tui/ rather than Bubble Tea.
opentui is a Zig core with TypeScript bindings and no Go bindings, so this half
of the tool can't live in the Go binary; it compiles with Bun into a sibling
executable (rtsp-streamer-tui) that `rtsp-streamer tui` execs.
Everything that isn't presentation stays in Go, reached over three JSON
commands. The configurator holds no credentials and never writes the config
itself:
config export the config, plus limits like max_tiles
config apply (stdin) merge cameras/layouts/active_layout, validate, save
atomically, reload the daemon
discover --json Protect discovery, writing nothing
Two properties of that split are deliberate:
- The controller password never crosses the bridge. It's json:"-" on the way
out, and apply only merges the three keys the TUI edits, so it can't be
clobbered on the way back in either.
- apply re-reads the file before merging, so an editor left open for an hour can
no longer overwrite a `views import`, a `layout set`, or a hand edit made in
the meantime.
Discovery is previewable as a result: `discover --json` writes nothing, the
merge happens in the TUI, and nothing reaches disk until you save. Only
--enable-rtsp has a side effect, and it's on the controller.
Config structs gain json tags mirroring their yaml ones so the config
round-trips through the bridge under the same key names it has on disk, and
maxGridDim moves to config.MaxGridDim so the CLI and both configurators enforce
one ceiling. The write path is byte-for-byte identical to `layout set`, checked
against a copy of a live config.
Visible change: the grid editor draws real bordered boxes, so a spanning tile is
one box instead of an origin cell plus "·" continuation marks, and the
header-offset arithmetic in mouse.go is gone — the framework hit-tests list
rows. Keybindings, the lipgloss palette and the screen flow are carried over
unchanged; S now saves from anywhere.
The Bubble Tea version stays as `tui --legacy`. It's compiled into the Go binary
and needs no Bun, and on a headless Pi the TUI is the only config UI there is,
so a fallback is worth its weight. The cost of the new one is size: ~120 MB
against ~13 MB, since Bun embeds its runtime and opentui's native library.
Tests: 67 bun tests drive the real (in-memory) opentui renderer, including mouse
click and drag, plus tsc --noEmit. `make test-tui` runs both, and
scripts/preview.ts dumps every screen as text without needing a terminal.
Three bugs found during the port are documented in tui/README.md, since none are
apparent from the code: overlapping cell borders render as ┌ where a lattice
needs ┬; a drag dies after the first resize if the tree is rebuilt, because the
renderer captures the press-target renderable; and a rebuilt box has no computed
layout until the next frame, so its screenX reads 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
553 lines
19 KiB
Go
553 lines
19 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.
|
|
//
|
|
// The json tags mirror the yaml ones so the config can round-trip through the
|
|
// `config export` / `config apply` bridge (used by the opentui configurator in
|
|
// tui/) with exactly the same key names it has on 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" json:"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" json:"display"`
|
|
|
|
// Player holds mpv tuning shared by every stream.
|
|
Player Player `yaml:"player" json:"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" json:"cameras"`
|
|
|
|
// Layouts are named preset grids.
|
|
Layouts []Layout `yaml:"layouts" json:"layouts"`
|
|
|
|
// ActiveLayout is the name of the layout the daemon renders.
|
|
ActiveLayout string `yaml:"active_layout" json:"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" json:"view_refresh_seconds,omitempty"`
|
|
|
|
// Clock overlays a live clock in a screen corner.
|
|
Clock Clock `yaml:"clock,omitempty" json:"clock"`
|
|
}
|
|
|
|
// 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" json:"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" json:"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" json:"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" json:"corner,omitempty"`
|
|
// FontSize is the glyph height in pixels (default 44).
|
|
FontSize int `yaml:"font_size,omitempty" json:"font_size,omitempty"`
|
|
// Width/Height are the overlay window size in pixels (defaults 300x72).
|
|
Width int `yaml:"width,omitempty" json:"width,omitempty"`
|
|
Height int `yaml:"height,omitempty" json:"height,omitempty"`
|
|
// Margin is the gap from the screen edges in pixels (default 24).
|
|
Margin int `yaml:"margin,omitempty" json:"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" json:"background_opacity,omitempty"`
|
|
}
|
|
|
|
// Controller holds UniFi Protect connection details.
|
|
type Controller struct {
|
|
Host string `yaml:"host" json:"host"` // hostname or IP of the UniFi OS console
|
|
Username string `yaml:"username" json:"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.
|
|
//
|
|
// json:"-" keeps the plaintext password out of `config export`: the
|
|
// opentui configurator never needs it (discovery runs in-process here) and
|
|
// `config apply` only merges the fields the TUI actually edits, so the
|
|
// secret never crosses the bridge in either direction.
|
|
Password string `yaml:"password,omitempty" json:"-"`
|
|
PasswordEnv string `yaml:"password_env,omitempty" json:"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" json:"verify_tls"`
|
|
// RTSPPort is the Protect RTSPS port (7441 on current firmware).
|
|
RTSPPort int `yaml:"rtsp_port,omitempty" json:"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" json:"width,omitempty"`
|
|
Height int `yaml:"height,omitempty" json:"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" json:"hwdec"`
|
|
// Profile applies an mpv profile; "low-latency" trims buffering for live
|
|
// feeds. Empty disables it.
|
|
Profile string `yaml:"profile" json:"profile"`
|
|
// ExtraArgs are appended verbatim to every mpv invocation.
|
|
ExtraArgs []string `yaml:"extra_args,omitempty" json:"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" json:"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" json:"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" json:"resync_seconds,omitempty"`
|
|
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
|
// that exited or stalled.
|
|
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty" json:"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" json:"id,omitempty"`
|
|
// Name is the human label and the key layouts reference. Must be unique.
|
|
Name string `yaml:"name" json:"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" json:"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" json:"streams,omitempty"`
|
|
// Disabled hides the camera from selection without deleting it.
|
|
Disabled bool `yaml:"disabled,omitempty" json:"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
|
|
|
|
// MaxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity;
|
|
// the number of *tiles* (cameras) is capped separately by MaxTiles. It lives
|
|
// here rather than in an editor so the CLI, the Go TUI and the opentui
|
|
// configurator all enforce the same ceiling.
|
|
const MaxGridDim = 8
|
|
|
|
// 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" json:"name"`
|
|
// Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and
|
|
// sized in these cells.
|
|
Grid string `yaml:"grid" json:"grid"`
|
|
// Tiles is the placement model. Preferred over Slots.
|
|
Tiles []Tile `yaml:"tiles,omitempty" json:"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" json:"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" json:"protect_view,omitempty"`
|
|
}
|
|
|
|
// Tile places one camera at a rectangular region of the base grid.
|
|
type Tile struct {
|
|
Camera string `yaml:"camera" json:"camera"`
|
|
Col int `yaml:"col" json:"col"`
|
|
Row int `yaml:"row" json:"row"`
|
|
ColSpan int `yaml:"colspan,omitempty" json:"colspan,omitempty"` // defaults to 1
|
|
RowSpan int `yaml:"rowspan,omitempty" json:"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" json:"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)
|
|
}
|