adding to git.i0t.app

This commit is contained in:
Levi Woodard
2026-07-01 18:21:14 -05:00
commit 55a8ea4bee
26 changed files with 3534 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
// Package compositor drives a running sway session to tile mpv windows into a
// grid. The daemon does NOT launch sway; the standard kiosk pattern is for
// sway (started at boot via autologin) to exec the daemon, so SWAYSOCK and
// WAYLAND_DISPLAY are inherited. This package shells out to swaymsg, which is
// always present alongside sway and speaks the sway IPC for us.
package compositor
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"time"
)
// Rect is a pixel rectangle on the output.
type Rect struct {
X, Y, W, H int
}
// GridRects splits a WxH area into cols*rows cells in row-major order. The
// final column and row absorb any rounding remainder so there are no gaps.
func GridRects(width, height, cols, rows int) []Rect {
rects := make([]Rect, 0, cols*rows)
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
x := c * width / cols
y := r * height / rows
// Right/bottom edge computed from the next cell boundary to avoid
// cumulative rounding gaps.
x2 := (c + 1) * width / cols
y2 := (r + 1) * height / rows
if c == cols-1 {
x2 = width
}
if r == rows-1 {
y2 = height
}
rects = append(rects, Rect{X: x, Y: y, W: x2 - x, H: y2 - y})
}
}
return rects
}
// TileRect computes the pixel rectangle for a tile spanning (colspan x rowspan)
// cells starting at (col,row) in a cols x rows base grid over a WxH output.
// Boundaries are computed from cell edges so adjacent tiles meet exactly and
// the far edges reach the full width/height with no rounding gaps.
func TileRect(width, height, cols, rows, col, row, colspan, rowspan int) Rect {
x := col * width / cols
y := row * height / rows
x2 := (col + colspan) * width / cols
y2 := (row + rowspan) * height / rows
if col+colspan >= cols {
x2 = width
}
if row+rowspan >= rows {
y2 = height
}
return Rect{X: x, Y: y, W: x2 - x, H: y2 - y}
}
// Client talks to sway via swaymsg.
type Client struct {
bin string
}
// New returns a compositor client. It verifies swaymsg is on PATH.
func New() (*Client, error) {
bin, err := exec.LookPath("swaymsg")
if err != nil {
return nil, fmt.Errorf("swaymsg not found on PATH: %w", err)
}
return &Client{bin: bin}, nil
}
// run executes a raw sway command string.
func (c *Client) run(ctx context.Context, command string) error {
out, err := exec.CommandContext(ctx, c.bin, command).CombinedOutput()
if err != nil {
return fmt.Errorf("swaymsg %q: %w: %s", command, err, out)
}
return nil
}
// Output describes a connected display from `swaymsg -t get_outputs`.
type Output struct {
Name string `json:"name"`
Active bool `json:"active"`
Focused bool `json:"focused"`
CurrentMode struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"current_mode"`
Rect Rect `json:"-"`
}
// PrimaryOutput returns the focused active output (or the first active one),
// used to auto-detect resolution when the config doesn't pin it.
func (c *Client) PrimaryOutput(ctx context.Context) (*Output, error) {
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_outputs", "-r").Output()
if err != nil {
return nil, fmt.Errorf("get_outputs: %w", err)
}
var outputs []Output
if err := json.Unmarshal(out, &outputs); err != nil {
return nil, err
}
var first *Output
for i := range outputs {
o := &outputs[i]
if !o.Active {
continue
}
if first == nil {
first = o
}
if o.Focused {
return o, nil
}
}
if first == nil {
return nil, fmt.Errorf("no active output found")
}
return first, nil
}
// treeNode is the recursive shape of `swaymsg -t get_tree`.
type treeNode struct {
PID int `json:"pid"`
Name string `json:"name"`
AppID string `json:"app_id"`
Nodes []treeNode `json:"nodes"`
Float []treeNode `json:"floating_nodes"`
}
func (n *treeNode) walk(fn func(*treeNode)) {
fn(n)
for i := range n.Nodes {
n.Nodes[i].walk(fn)
}
for i := range n.Float {
n.Float[i].walk(fn)
}
}
// hasPID reports whether a window with the given pid currently exists.
func (c *Client) hasPID(ctx context.Context, pid int) (bool, error) {
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output()
if err != nil {
return false, fmt.Errorf("get_tree: %w", err)
}
var root treeNode
if err := json.Unmarshal(out, &root); err != nil {
return false, err
}
found := false
root.walk(func(n *treeNode) {
if n.PID == pid && (n.AppID != "" || n.Name != "") {
found = true
}
})
return found, nil
}
// WaitForWindow blocks until a window owned by pid maps, or ctx/timeout fires.
func (c *Client) WaitForWindow(ctx context.Context, pid int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(150 * time.Millisecond)
defer ticker.Stop()
for {
ok, err := c.hasPID(ctx, pid)
if err == nil && ok {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if time.Now().After(deadline) {
return fmt.Errorf("window for pid %d did not appear within %s", pid, timeout)
}
}
}
}
// Place floats and positions the window owned by pid at the given rect. Using
// the pid criterion means we never depend on window titles or app-ids.
func (c *Client) Place(ctx context.Context, pid int, r Rect) error {
cmd := fmt.Sprintf(
"[pid=%d] floating enable, border none, move absolute position %d %d, resize set %d %d",
pid, r.X, r.Y, r.W, r.H,
)
return c.run(ctx, cmd)
}
// PrepareForMPV installs global rules so every mpv window is borderless and
// floating the moment it maps, avoiding a flash of tiled/bordered video before
// Place runs. Safe to call repeatedly.
func (c *Client) PrepareForMPV(ctx context.Context) error {
return c.run(ctx, `for_window [app_id="mpv"] floating enable, border none`)
}

View File

@@ -0,0 +1,35 @@
package compositor
import "testing"
func TestGridRectsCoverExactly(t *testing.T) {
cases := []struct {
w, h, cols, rows int
}{
{1920, 1080, 2, 2},
{1920, 1080, 3, 3},
{1920, 1080, 1, 1},
{1366, 768, 3, 2}, // odd dimensions to exercise remainder handling
}
for _, tc := range cases {
rects := GridRects(tc.w, tc.h, tc.cols, tc.rows)
if len(rects) != tc.cols*tc.rows {
t.Fatalf("%dx%d grid %dx%d: got %d rects", tc.w, tc.h, tc.cols, tc.rows, len(rects))
}
// Sum of areas must equal the whole output with no gaps or overlap on
// the axis boundaries: verify the last column reaches the right edge
// and the last row reaches the bottom edge.
last := rects[len(rects)-1]
if last.X+last.W != tc.w {
t.Errorf("%dx%d grid %dx%d: last cell right edge %d != %d", tc.w, tc.h, tc.cols, tc.rows, last.X+last.W, tc.w)
}
if last.Y+last.H != tc.h {
t.Errorf("%dx%d grid %dx%d: last cell bottom edge %d != %d", tc.w, tc.h, tc.cols, tc.rows, last.Y+last.H, tc.h)
}
for i, r := range rects {
if r.W <= 0 || r.H <= 0 {
t.Errorf("cell %d has non-positive size %+v", i, r)
}
}
}
}

View File

@@ -0,0 +1,27 @@
package compositor
import "testing"
func TestTileRect(t *testing.T) {
W, H := 1920, 1080
// A 1x1 tile at (0,0) in a 4x4 grid.
got := TileRect(W, H, 4, 4, 0, 0, 1, 1)
if got.W != W/4 || got.H != H/4 || got.X != 0 || got.Y != 0 {
t.Errorf("1x1 top-left: got %+v", got)
}
// A tile spanning the full width/height must reach the exact edges.
full := TileRect(W, H, 4, 4, 0, 0, 4, 4)
if full.X != 0 || full.Y != 0 || full.W != W || full.H != H {
t.Errorf("full-span: got %+v want full frame", full)
}
// A 3x3 main tile plus a right column: the main reaches 3/4 width, the
// side tile fills the remainder to the exact right edge.
main := TileRect(W, H, 4, 4, 0, 0, 3, 4)
side := TileRect(W, H, 4, 4, 3, 0, 1, 1)
if main.X+main.W != side.X {
t.Errorf("main right edge %d should meet side left edge %d", main.X+main.W, side.X)
}
if side.X+side.W != W {
t.Errorf("side tile right edge %d should reach %d", side.X+side.W, W)
}
}

378
internal/config/config.go Normal file
View 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)
}

View File

@@ -0,0 +1,41 @@
package config
import "testing"
func TestLayoutDimensions(t *testing.T) {
good := map[string][2]int{"2x2": {2, 2}, "3X3": {3, 3}, " 4x2 ": {4, 2}, "1x1": {1, 1}}
for grid, want := range good {
l := Layout{Name: "t", Grid: grid}
c, r, err := l.Dimensions()
if err != nil {
t.Fatalf("grid %q: unexpected error %v", grid, err)
}
if c != want[0] || r != want[1] {
t.Errorf("grid %q: got %dx%d want %dx%d", grid, c, r, want[0], want[1])
}
}
for _, bad := range []string{"", "2", "2x", "x2", "0x2", "axb"} {
if _, _, err := (Layout{Name: "t", Grid: bad}).Dimensions(); err == nil {
t.Errorf("grid %q: expected error", bad)
}
}
}
func TestValidateRejectsUnknownCameraRef(t *testing.T) {
c := &Config{
Cameras: []Camera{{Name: "front"}},
Layouts: []Layout{{Name: "l", Grid: "2x2", Slots: []string{"front", "missing", "", ""}}},
}
c.Defaults()
if err := c.Validate(); err == nil {
t.Fatal("expected validation error for unknown camera reference")
}
}
func TestValidateRejectsTooManySlots(t *testing.T) {
c := &Config{Layouts: []Layout{{Name: "l", Grid: "1x1", Slots: []string{"", ""}}}}
c.Defaults()
if err := c.Validate(); err == nil {
t.Fatal("expected validation error for slot overflow")
}
}

View File

@@ -0,0 +1,58 @@
package config
import "testing"
func TestEffectiveTilesFromSlots(t *testing.T) {
l := Layout{Name: "l", Grid: "2x2", Slots: []string{"a", "", "", "b"}}
tiles := l.EffectiveTiles()
if len(tiles) != 2 {
t.Fatalf("got %d tiles, want 2", len(tiles))
}
// "a" at cell 0 -> (0,0); "b" at cell 3 -> (1,1).
if tiles[0].Camera != "a" || tiles[0].Col != 0 || tiles[0].Row != 0 {
t.Errorf("tile0 = %+v", tiles[0])
}
if tiles[1].Camera != "b" || tiles[1].Col != 1 || tiles[1].Row != 1 {
t.Errorf("tile1 = %+v", tiles[1])
}
}
func TestValidateTiles(t *testing.T) {
base := func(tiles []Tile) *Config {
c := &Config{
Cameras: []Camera{{Name: "a"}, {Name: "b"}},
Layouts: []Layout{{Name: "l", Grid: "4x4", Tiles: tiles}},
}
c.Defaults()
return c
}
// Valid: a 3x4 main plus a 1x1 side.
if err := base([]Tile{{Camera: "a", Col: 0, Row: 0, ColSpan: 3, RowSpan: 4}, {Camera: "b", Col: 3, Row: 0}}).Validate(); err != nil {
t.Errorf("valid layout rejected: %v", err)
}
// Overlap.
if err := base([]Tile{{Camera: "a", Col: 0, Row: 0, ColSpan: 2, RowSpan: 2}, {Camera: "b", Col: 1, Row: 1}}).Validate(); err == nil {
t.Error("expected overlap error")
}
// Out of bounds.
if err := base([]Tile{{Camera: "a", Col: 3, Row: 0, ColSpan: 2, RowSpan: 1}}).Validate(); err == nil {
t.Error("expected out-of-bounds error")
}
// Unknown camera.
if err := base([]Tile{{Camera: "ghost", Col: 0, Row: 0}}).Validate(); err == nil {
t.Error("expected unknown-camera error")
}
}
func TestValidateTilesCap(t *testing.T) {
var tiles []Tile
for i := 0; i < MaxTiles+1; i++ {
tiles = append(tiles, Tile{Camera: "a", Col: i, Row: 0})
}
c := &Config{Cameras: []Camera{{Name: "a"}}, Layouts: []Layout{{Name: "l", Grid: "20x1", Tiles: tiles}}}
c.Defaults()
if err := c.Validate(); err == nil {
t.Errorf("expected error exceeding %d-tile cap", MaxTiles)
}
}

316
internal/daemon/daemon.go Normal file
View File

@@ -0,0 +1,316 @@
// Package daemon is the orchestrator: it reads the config, asks the compositor
// for the output geometry, launches one supervised mpv per occupied grid slot,
// tiles them, and exposes a control socket so the layout can be switched live.
package daemon
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"sync"
"time"
"github.com/lwoodard/rtsp-streamer/internal/compositor"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/ipc"
"github.com/lwoodard/rtsp-streamer/internal/player"
)
// Daemon owns the running video wall.
type Daemon struct {
cfgPath string
log *slog.Logger
comp *compositor.Client
runDir string
mu sync.Mutex
cfg *config.Config
players []*player.Player
layout string
cancelLo context.CancelFunc // cancels the current layout's supervisors
wg sync.WaitGroup
}
// New constructs a daemon bound to a config path.
func New(cfgPath string, log *slog.Logger) (*Daemon, error) {
comp, err := compositor.New()
if err != nil {
return nil, err
}
runDir := ipc.RunDir()
if err := os.MkdirAll(runDir, 0o700); err != nil {
return nil, err
}
return &Daemon{cfgPath: cfgPath, log: log, comp: comp, runDir: runDir}, nil
}
// Run starts the wall and blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
if err := d.comp.PrepareForMPV(ctx); err != nil {
d.log.Warn("could not preinstall mpv window rules", "err", err)
}
if err := d.reload(ctx); err != nil {
return err
}
go d.serveControl(ctx)
go d.healthLoop(ctx)
<-ctx.Done()
d.log.Info("shutting down")
d.stopLayout()
return nil
}
// resolution returns the render size, from config or the live output.
func (d *Daemon) resolution(ctx context.Context, cfg *config.Config) (int, int) {
if cfg.Display.Width > 0 && cfg.Display.Height > 0 {
return cfg.Display.Width, cfg.Display.Height
}
if out, err := d.comp.PrimaryOutput(ctx); err == nil && out.CurrentMode.Width > 0 {
return out.CurrentMode.Width, out.CurrentMode.Height
}
d.log.Warn("falling back to 1920x1080; set display.width/height to override")
return 1920, 1080
}
// reload re-reads config from disk and applies the active layout.
func (d *Daemon) reload(ctx context.Context) error {
cfg, err := config.Load(d.cfgPath)
if err != nil {
return err
}
d.mu.Lock()
d.cfg = cfg
d.mu.Unlock()
name := cfg.ActiveLayout
if name == "" {
d.log.Warn("no active_layout set; nothing to display")
d.stopLayout()
return nil
}
return d.applyLayout(ctx, name)
}
// applyLayout tears down the current wall and builds the named layout.
func (d *Daemon) applyLayout(ctx context.Context, name string) error {
d.mu.Lock()
cfg := d.cfg
d.mu.Unlock()
layout := cfg.LayoutByName(name)
if layout == nil {
return fmt.Errorf("layout %q not found", name)
}
cols, rows, err := layout.Dimensions()
if err != nil {
return err
}
w, h := d.resolution(ctx, cfg)
tiles := layout.EffectiveTiles()
d.stopLayout()
loCtx, cancel := context.WithCancel(ctx)
var players []*player.Player
for slot, tile := range tiles {
if tile.Camera == "" {
continue
}
cam := cfg.CameraByName(tile.Camera)
if cam == nil || cam.Disabled || cam.RTSP == "" {
d.log.Warn("skipping tile: camera unavailable", "slot", slot, "camera", tile.Camera)
continue
}
cs, rs := tile.Span()
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
p := player.New(slot, cam.Name, cam.RTSP, cfg.Player, d.runDir, d.log)
players = append(players, p)
d.wg.Add(1)
go func() { defer d.wg.Done(); p.Supervise(loCtx) }()
// Position the window once it maps. Done per-tile so a slow camera
// doesn't block the others.
d.wg.Add(1)
go func(p *player.Player, rect compositor.Rect) {
defer d.wg.Done()
d.placeWhenReady(loCtx, p, rect)
}(p, rect)
}
d.mu.Lock()
d.players = players
d.layout = name
d.cancelLo = cancel
d.cfg.ActiveLayout = name
d.mu.Unlock()
d.log.Info("layout applied", "layout", name, "tiles", len(players), "grid", layout.Grid, "res", fmt.Sprintf("%dx%d", w, h))
return nil
}
// placeWhenReady waits for the mpv window to map then tiles it, retrying while
// the layout is active (Supervise may relaunch mpv with a new pid).
func (d *Daemon) placeWhenReady(ctx context.Context, p *player.Player, rect compositor.Rect) {
var lastPID int
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
if ctx.Err() != nil {
return
}
pid := p.PID()
if pid != 0 && pid != lastPID {
if err := d.comp.WaitForWindow(ctx, pid, 15*time.Second); err == nil {
if err := d.comp.Place(ctx, pid, rect); err != nil {
d.log.Warn("place failed", "slot", p.Slot, "err", err)
} else {
lastPID = pid
d.log.Debug("window placed", "slot", p.Slot, "pid", pid, "rect", rect)
}
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// stopLayout cancels supervisors and kills current mpv processes.
func (d *Daemon) stopLayout() {
d.mu.Lock()
cancel := d.cancelLo
players := d.players
d.cancelLo = nil
d.players = nil
d.mu.Unlock()
if cancel != nil {
cancel()
}
for _, p := range players {
p.Stop()
}
d.wg.Wait()
}
// healthLoop periodically nudges stalled streams. Supervise already restarts
// exited mpv; this catches the "process alive but frozen" case.
func (d *Daemon) healthLoop(ctx context.Context) {
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
stalls := map[int]int{}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
d.mu.Lock()
players := append([]*player.Player(nil), d.players...)
d.mu.Unlock()
for _, p := range players {
if !p.Running() {
continue
}
if p.Healthy() {
stalls[p.Slot] = 0
continue
}
stalls[p.Slot]++
if stalls[p.Slot] >= 2 {
d.log.Warn("stream stalled, forcing restart", "slot", p.Slot, "camera", p.Name)
p.Stop() // Supervise relaunches
stalls[p.Slot] = 0
}
}
}
}
// serveControl accepts control-socket connections for status/reload/set-layout.
func (d *Daemon) serveControl(ctx context.Context) {
path := ipc.SocketPath()
_ = os.Remove(path)
ln, err := net.Listen("unix", path)
if err != nil {
d.log.Error("control socket listen failed", "err", err)
return
}
go func() { <-ctx.Done(); ln.Close(); os.Remove(path) }()
d.log.Info("control socket ready", "path", path)
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
go d.handleControl(ctx, conn)
}
}
func (d *Daemon) handleControl(ctx context.Context, conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
var req ipc.Request
if err := json.NewDecoder(conn).Decode(&req); err != nil {
return
}
resp := d.dispatch(ctx, req)
_ = json.NewEncoder(conn).Encode(resp)
}
func (d *Daemon) dispatch(ctx context.Context, req ipc.Request) ipc.Response {
switch req.Cmd {
case "status":
return d.status()
case "reload":
if err := d.reload(ctx); err != nil {
return ipc.Response{OK: false, Error: err.Error()}
}
return d.status()
case "set-layout":
if err := d.applyLayout(ctx, req.Name); err != nil {
return ipc.Response{OK: false, Error: err.Error()}
}
// Persist the choice so a restart keeps it.
if err := d.persistActiveLayout(req.Name); err != nil {
d.log.Warn("could not persist active layout", "err", err)
}
return d.status()
default:
return ipc.Response{OK: false, Error: fmt.Sprintf("unknown command %q", req.Cmd)}
}
}
func (d *Daemon) persistActiveLayout(name string) error {
cfg, err := config.Load(d.cfgPath)
if err != nil {
return err
}
cfg.ActiveLayout = name
return config.Save(d.cfgPath, cfg)
}
func (d *Daemon) status() ipc.Response {
d.mu.Lock()
defer d.mu.Unlock()
resp := ipc.Response{OK: true, ActiveLayout: d.layout}
for _, p := range d.players {
resp.Slots = append(resp.Slots, ipc.SlotStatus{
Slot: p.Slot,
Camera: p.Name,
PID: p.PID(),
Running: p.Running(),
Healthy: p.Healthy(),
})
}
return resp
}

75
internal/ipc/ipc.go Normal file
View File

@@ -0,0 +1,75 @@
// Package ipc defines the tiny control protocol shared by the daemon (server)
// and the CLI/TUI (clients). Messages are newline-delimited JSON over a unix
// socket, so switching layouts or reading status never requires restarting
// the video wall.
package ipc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"time"
)
// Request is a command sent to the daemon.
type Request struct {
Cmd string `json:"cmd"` // "status" | "reload" | "set-layout"
Name string `json:"name,omitempty"` // layout name for set-layout
}
// SlotStatus reports one grid cell's state.
type SlotStatus struct {
Slot int `json:"slot"`
Camera string `json:"camera"`
PID int `json:"pid"`
Running bool `json:"running"`
Healthy bool `json:"healthy"`
}
// Response is the daemon's reply.
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
ActiveLayout string `json:"active_layout,omitempty"`
Slots []SlotStatus `json:"slots,omitempty"`
}
// SocketPath returns the control socket path inside the runtime dir.
func SocketPath() string {
return filepath.Join(RunDir(), "control.sock")
}
// RunDir is the per-user runtime directory for sockets, created on demand.
func RunDir() string {
base := os.Getenv("XDG_RUNTIME_DIR")
if base == "" {
base = filepath.Join(os.TempDir(), "rtsp-streamer-"+strconv.Itoa(os.Getuid()))
} else {
base = filepath.Join(base, "rtsp-streamer")
}
return base
}
// Send dials the daemon, sends one request, and returns the reply.
func Send(req Request) (*Response, error) {
conn, err := net.DialTimeout("unix", SocketPath(), 3*time.Second)
if err != nil {
return nil, fmt.Errorf("cannot reach daemon (is it running?): %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
enc := json.NewEncoder(conn)
if err := enc.Encode(req); err != nil {
return nil, err
}
var resp Response
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&resp); err != nil {
return nil, err
}
return &resp, nil
}

233
internal/player/player.go Normal file
View File

@@ -0,0 +1,233 @@
// Package player manages one mpv process per camera stream. Each stream runs
// independently so a single dead camera never disturbs the rest of the wall;
// the manager relaunches only the slot that failed, with backoff.
package player
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
// Player supervises a single mpv instance bound to one grid slot.
type Player struct {
Slot int // grid cell index (row-major)
Name string // camera name, for logs/titles
URL string // RTSP(S) source
cfg config.Player
ipcPath string
runDir string
log *slog.Logger
mu sync.Mutex
cmd *exec.Cmd
started time.Time
}
// New creates a player. runDir is where the mpv IPC socket lives.
func New(slot int, name, url string, cfg config.Player, runDir string, log *slog.Logger) *Player {
return &Player{
Slot: slot,
Name: name,
URL: url,
cfg: cfg,
runDir: runDir,
ipcPath: filepath.Join(runDir, fmt.Sprintf("mpv-slot-%d.sock", slot)),
log: log.With("slot", slot, "camera", name),
}
}
// Title is the window title mpv advertises; the compositor could match on it,
// though we prefer matching by PID.
func (p *Player) Title() string {
return fmt.Sprintf("rtsp-streamer:slot-%d", p.Slot)
}
func (p *Player) args() []string {
args := []string{
"--no-config",
"--force-window=yes",
"--idle=no",
"--keep-open=no",
"--no-osc",
"--no-input-default-bindings",
"--input-cursor=no",
"--cursor-autohide=always",
"--no-border",
"--fullscreen=no", // we tile via the compositor, not fullscreen
"--title=" + p.Title(),
"--input-ipc-server=" + p.ipcPath,
"--hwdec=" + p.cfg.HWDec,
// Live-stream hygiene: prefer TCP transport, keep buffers small.
"--rtsp-transport=tcp",
"--profile=low-latency",
"--cache=no",
"--demuxer-lavf-o=stimeout=5000000",
}
if p.cfg.Profile != "" && p.cfg.Profile != "low-latency" {
args = append(args, "--profile="+p.cfg.Profile)
}
args = append(args, p.cfg.ExtraArgs...)
args = append(args, p.URL)
return args
}
// PID returns the running mpv process id, or 0 if not running.
func (p *Player) PID() int {
p.mu.Lock()
defer p.mu.Unlock()
if p.cmd == nil || p.cmd.Process == nil {
return 0
}
return p.cmd.Process.Pid
}
// Running reports whether the process is currently alive.
func (p *Player) Running() bool {
return p.PID() != 0
}
// start launches mpv once. Caller owns retry/backoff.
func (p *Player) start(ctx context.Context) error {
if err := os.Remove(p.ipcPath); err != nil && !os.IsNotExist(err) {
p.log.Warn("stale ipc socket", "err", err)
}
cmd := exec.CommandContext(ctx, "mpv", p.args()...)
// Inherit the caller's environment (WAYLAND_DISPLAY etc. must be set).
cmd.Env = os.Environ()
// Discard mpv's chatty stdout/stderr; errors surface via exit code.
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting mpv: %w", err)
}
p.mu.Lock()
p.cmd = cmd
p.started = time.Now()
p.mu.Unlock()
p.log.Info("mpv started", "pid", cmd.Process.Pid)
return nil
}
// Supervise runs mpv and relaunches it whenever it exits, until ctx is
// cancelled. Backoff prevents a hot loop when a camera is unreachable.
func (p *Player) Supervise(ctx context.Context) {
backoff := time.Duration(p.cfg.RestartBackoffSeconds) * time.Second
if backoff <= 0 {
backoff = 3 * time.Second
}
for {
if ctx.Err() != nil {
return
}
if err := p.start(ctx); err != nil {
p.log.Error("failed to start mpv", "err", err)
if !sleep(ctx, backoff) {
return
}
continue
}
p.mu.Lock()
cmd := p.cmd
p.mu.Unlock()
err := cmd.Wait()
p.mu.Lock()
p.cmd = nil
p.mu.Unlock()
if ctx.Err() != nil {
return
}
p.log.Warn("mpv exited, will restart", "err", err, "after", backoff)
if !sleep(ctx, backoff) {
return
}
}
}
// Stop terminates the mpv process (SIGTERM, then SIGKILL after a grace period).
func (p *Player) Stop() {
p.mu.Lock()
cmd := p.cmd
p.mu.Unlock()
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Signal(syscall.SIGTERM)
done := make(chan struct{})
go func() { _, _ = cmd.Process.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
_ = cmd.Process.Kill()
}
}
// Command sends a JSON IPC command to mpv and returns the decoded reply. Used
// for health probes and live property changes.
func (p *Player) Command(args ...any) (map[string]any, error) {
conn, err := net.DialTimeout("unix", p.ipcPath, 2*time.Second)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
payload, err := json.Marshal(map[string]any{"command": args})
if err != nil {
return nil, err
}
if _, err := conn.Write(append(payload, '\n')); err != nil {
return nil, err
}
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
var reply map[string]any
if err := json.Unmarshal(scanner.Bytes(), &reply); err != nil {
continue
}
// mpv emits async events too; the command reply carries "error".
if _, ok := reply["error"]; ok {
return reply, nil
}
}
return nil, fmt.Errorf("no reply from mpv ipc")
}
// Healthy probes mpv over IPC and reports whether it is actively playing (has
// a finite time position advancing). A false result signals the daemon to
// consider restarting the slot even if the process is still alive (frozen).
func (p *Player) Healthy() bool {
if !p.Running() {
return false
}
reply, err := p.Command("get_property", "time-pos")
if err != nil {
return false
}
return reply["error"] == "success"
}
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

358
internal/protect/protect.go Normal file
View File

@@ -0,0 +1,358 @@
// Package protect is a minimal client for the UniFi Protect local API.
//
// UniFi Protect has no officially documented public API, but the local
// endpoints used here (/api/auth/login and /proxy/protect/api/bootstrap) are
// stable and are the same ones the Home Assistant integration and the
// uiprotect/pyunifiprotect libraries rely on. We authenticate as a local
// Protect user, read the bootstrap document, and construct RTSPS URLs from
// each camera's enabled channel alias.
package protect
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"time"
)
// Client talks to a single UniFi Protect controller.
type Client struct {
host string
rtspPort int
http *http.Client
csrf string
}
// Camera is a discovered Protect camera with its resolved stream URLs.
type Camera struct {
ID string
Name string
State string // "CONNECTED", "DISCONNECTED", ...
Channels []Channel
}
// Channel is one encoding profile (high/medium/low) on a camera.
type Channel struct {
ID int
Name string // "High", "Medium", "Low"
Width int
Height int
RTSPEnabled bool
RTSPAlias string
}
// New builds a client. verifyTLS=false accepts the console's self-signed cert.
func New(host string, rtspPort int, verifyTLS bool) (*Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
if rtspPort == 0 {
rtspPort = 7441
}
return &Client{
host: host,
rtspPort: rtspPort,
http: &http.Client{
Timeout: 20 * time.Second,
Jar: jar,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS}, //nolint:gosec // self-signed console cert
},
},
}, nil
}
// Login authenticates and captures the session cookie + CSRF token. UniFi OS
// returns the CSRF token in a response header on successful login.
func (c *Client) Login(ctx context.Context, username, password string) error {
body, _ := json.Marshal(map[string]any{
"username": username,
"password": password,
"rememberMe": true,
})
url := fmt.Sprintf("https://%s/api/auth/login", c.host)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("connecting to controller %s: %w", c.host, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("login failed (%s): %s", resp.Status, strings.TrimSpace(string(snippet)))
}
// UniFi OS exposes the CSRF token via header; capture whichever casing.
if tok := resp.Header.Get("X-CSRF-Token"); tok != "" {
c.csrf = tok
} else if tok := resp.Header.Get("X-Updated-CSRF-Token"); tok != "" {
c.csrf = tok
}
return nil
}
// bootstrap is the subset of the Protect bootstrap document we care about.
type bootstrap struct {
Cameras []struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
IsRTSPEnabled bool `json:"isRtspEnabled"`
ChannelsWrapper []struct {
ID int `json:"id"`
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
IsRTSPEnabled bool `json:"isRtspEnabled"`
RTSPAlias string `json:"rtspAlias"`
} `json:"channels"`
} `json:"cameras"`
}
// Cameras fetches the bootstrap document and returns the camera list.
func (c *Client) Cameras(ctx context.Context) ([]Camera, error) {
url := fmt.Sprintf("https://%s/proxy/protect/api/bootstrap", c.host)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if c.csrf != "" {
req.Header.Set("X-CSRF-Token", c.csrf)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("bootstrap failed (%s): %s", resp.Status, strings.TrimSpace(string(snippet)))
}
var b bootstrap
if err := json.NewDecoder(resp.Body).Decode(&b); err != nil {
return nil, fmt.Errorf("decoding bootstrap: %w", err)
}
out := make([]Camera, 0, len(b.Cameras))
for _, bc := range b.Cameras {
cam := Camera{ID: bc.ID, Name: bc.Name, State: bc.State}
for _, ch := range bc.ChannelsWrapper {
cam.Channels = append(cam.Channels, Channel{
ID: ch.ID,
Name: ch.Name,
Width: ch.Width,
Height: ch.Height,
RTSPEnabled: ch.IsRTSPEnabled,
RTSPAlias: ch.RTSPAlias,
})
}
out = append(out, cam)
}
return out, nil
}
// StreamURL builds the RTSPS URL for a channel alias on this controller.
// enableSrtp is required by Protect's RTSPS endpoint.
func (c *Client) StreamURL(alias string) string {
return fmt.Sprintf("rtsps://%s:%d/%s?enableSrtp", c.host, c.rtspPort, alias)
}
// BestEnabledChannel returns the highest-resolution channel that has RTSP
// enabled, or nil if none are enabled. Preferring the top channel gives the
// sharpest wall tile; callers can pick a lower one for dense grids.
func (cam Camera) BestEnabledChannel() *Channel {
var best *Channel
for i := range cam.Channels {
ch := &cam.Channels[i]
if !ch.RTSPEnabled || ch.RTSPAlias == "" {
continue
}
if best == nil || ch.Width*ch.Height > best.Width*best.Height {
best = ch
}
}
return best
}
// LowestEnabledChannel returns the lowest-resolution enabled channel, useful
// for dense grids where a substream is plenty.
func (cam Camera) LowestEnabledChannel() *Channel {
var low *Channel
for i := range cam.Channels {
ch := &cam.Channels[i]
if !ch.RTSPEnabled || ch.RTSPAlias == "" {
continue
}
if low == nil || ch.Width*ch.Height < low.Width*low.Height {
low = ch
}
}
return low
}
// ChannelByPreference picks a channel to enable RTSP on, by preference
// "high" | "medium" | "low". It matches on the channel name first (Protect
// labels them "High"/"Medium"/"Low") and falls back to resolution ranking so
// it still works on cameras with unusual channel names.
func (cam Camera) ChannelByPreference(pref string) *Channel {
pref = strings.ToLower(strings.TrimSpace(pref))
for i := range cam.Channels {
if strings.ToLower(cam.Channels[i].Name) == pref {
return &cam.Channels[i]
}
}
if len(cam.Channels) == 0 {
return nil
}
var pick *Channel
for i := range cam.Channels {
ch := &cam.Channels[i]
if pick == nil {
pick = ch
continue
}
switch pref {
case "low":
if ch.Width*ch.Height < pick.Width*pick.Height {
pick = ch
}
default: // treat anything else as "highest resolution"
if ch.Width*ch.Height > pick.Width*pick.Height {
pick = ch
}
}
}
return pick
}
// do issues an authenticated request to a Protect API path (e.g.
// "/proxy/protect/api/cameras/<id>"), attaching the session cookie (via the
// client's jar) and the CSRF token. The caller closes the response body.
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
var rdr io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(raw)
}
url := fmt.Sprintf("https://%s%s", c.host, path)
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.csrf != "" {
req.Header.Set("X-CSRF-Token", c.csrf)
}
return c.http.Do(req)
}
// EnableRTSP turns on RTSP for one channel of a camera and returns the newly
// assigned rtspAlias. It reads the camera's current channels as raw JSON and
// flips only isRtspEnabled on the target channel before PATCHing them back, so
// no other encoder settings (bitrate, fps, ...) are disturbed.
func (c *Client) EnableRTSP(ctx context.Context, cameraID string, channelID int) (string, error) {
getResp, err := c.do(ctx, http.MethodGet, "/proxy/protect/api/cameras/"+cameraID, nil)
if err != nil {
return "", err
}
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(getResp.Body, 512))
return "", fmt.Errorf("fetching camera %s (%s): %s", cameraID, getResp.Status, strings.TrimSpace(string(snippet)))
}
// Keep channels as raw maps to preserve every field we don't touch.
var cam struct {
Channels []map[string]any `json:"channels"`
}
if err := json.NewDecoder(getResp.Body).Decode(&cam); err != nil {
return "", fmt.Errorf("decoding camera %s: %w", cameraID, err)
}
found := false
for _, ch := range cam.Channels {
id, ok := ch["id"].(float64)
if ok && int(id) == channelID {
ch["isRtspEnabled"] = true
found = true
}
}
if !found {
return "", fmt.Errorf("camera %s has no channel %d", cameraID, channelID)
}
patchResp, err := c.do(ctx, http.MethodPatch, "/proxy/protect/api/cameras/"+cameraID,
map[string]any{"channels": cam.Channels})
if err != nil {
return "", err
}
defer patchResp.Body.Close()
if patchResp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(patchResp.Body, 512))
return "", fmt.Errorf("enabling RTSP on camera %s (%s): %s", cameraID, patchResp.Status, strings.TrimSpace(string(snippet)))
}
var updated struct {
Channels []struct {
ID int `json:"id"`
RTSPAlias string `json:"rtspAlias"`
} `json:"channels"`
}
if err := json.NewDecoder(patchResp.Body).Decode(&updated); err != nil {
return "", fmt.Errorf("decoding RTSP-enable response: %w", err)
}
for _, ch := range updated.Channels {
if ch.ID == channelID {
if ch.RTSPAlias == "" {
return "", fmt.Errorf("camera %s channel %d still has no rtspAlias after enabling", cameraID, channelID)
}
return ch.RTSPAlias, nil
}
}
return "", fmt.Errorf("channel %d missing from RTSP-enable response", channelID)
}
// EnableMissing enables RTSP on the preferred channel for every camera that
// currently has no RTSP-enabled channel, mutating cams in place so their
// BestEnabledChannel/LowestEnabledChannel become usable. It returns the names
// it enabled and, per camera name, any error encountered.
func (c *Client) EnableMissing(ctx context.Context, cams []Camera, pref string) (enabled []string, failed map[string]error) {
failed = map[string]error{}
for i := range cams {
cam := &cams[i]
if cam.BestEnabledChannel() != nil {
continue
}
target := cam.ChannelByPreference(pref)
if target == nil {
failed[cam.Name] = fmt.Errorf("no channels to enable")
continue
}
alias, err := c.EnableRTSP(ctx, cam.ID, target.ID)
if err != nil {
failed[cam.Name] = err
continue
}
// Reflect the change in the in-memory model.
for j := range cam.Channels {
if cam.Channels[j].ID == target.ID {
cam.Channels[j].RTSPEnabled = true
cam.Channels[j].RTSPAlias = alias
}
}
enabled = append(enabled, cam.Name)
}
return enabled, failed
}

197
internal/tui/gridedit.go Normal file
View File

@@ -0,0 +1,197 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
// maxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity;
// the number of *tiles* (cameras) is separately capped at config.MaxTiles.
const maxGridDim = 8
// migrateToTiles converts a layout to the tile model in place so the grid
// editor always works on tiles (legacy slot layouts are upgraded on open).
func (m *model) migrateToTiles(idx int) {
l := &m.cfg.Layouts[idx]
l.Tiles = l.EffectiveTiles()
l.Slots = nil
}
func (m *model) curLayout() *config.Layout { return &m.cfg.Layouts[m.editLayout] }
// usedCameras is the set of camera names already placed in the current layout,
// so the picker can flag ones you'd be adding twice.
func (m *model) usedCameras() map[string]bool {
used := map[string]bool{}
for _, t := range m.curLayout().Tiles {
if t.Camera != "" {
used[t.Camera] = true
}
}
return used
}
func (m *model) gridDims() (cols, rows int) {
cols, rows, err := m.curLayout().Dimensions()
if err != nil || cols < 1 || rows < 1 {
return 1, 1
}
return cols, rows
}
// tileIndexAt returns the index of the tile covering (col,row), or -1.
func (m *model) tileIndexAt(col, row int) int {
for i, t := range m.curLayout().Tiles {
cs, rs := t.Span()
if col >= t.Col && col < t.Col+cs && row >= t.Row && row < t.Row+rs {
return i
}
}
return -1
}
// regionFree reports whether the rectangle fits the grid and overlaps no tile
// other than excludeIdx.
func (m *model) regionFree(excludeIdx, col, row, colspan, rowspan int) bool {
cols, rows := m.gridDims()
if col < 0 || row < 0 || col+colspan > cols || row+rowspan > rows {
return false
}
for i, t := range m.curLayout().Tiles {
if i == excludeIdx {
continue
}
cs, rs := t.Span()
if col < t.Col+cs && col+colspan > t.Col && row < t.Row+rs && row+rowspan > t.Row {
return false
}
}
return true
}
func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
cols, rows := m.gridDims()
switch msg.String() {
case "esc", "backspace":
m.screen, m.cursor = screenLayouts, m.editLayout
case "up", "k":
if m.edRow > 0 {
m.edRow--
}
case "down", "j":
if m.edRow < rows-1 {
m.edRow++
}
case "left", "h":
if m.edCol > 0 {
m.edCol--
}
case "right", "l":
if m.edCol < cols-1 {
m.edCol++
}
case "enter", "a", " ":
m.screen, m.cursor = screenCameraPicker, 0
case "c":
m.clearAt()
case "L": // grow wider (toward the right)
m.resizeTile(1, 0)
case "H": // shrink narrower (from the right)
m.resizeTile(-1, 0)
case "J": // grow taller (toward the bottom)
m.resizeTile(0, 1)
case "K": // shrink shorter (from the bottom)
m.resizeTile(0, -1)
case "]":
m.resizeGrid(1, 0)
case "[":
m.resizeGrid(-1, 0)
case "}":
m.resizeGrid(0, 1)
case "{":
m.resizeGrid(0, -1)
}
return m, nil
}
// assignCamera places the picker's choice at the cursor cell: updates the tile
// there, creates a 1x1 tile on an empty cell, or clears when choice is empty.
func (m *model) assignCamera(name string) {
l := m.curLayout()
idx := m.tileIndexAt(m.edCol, m.edRow)
if name == "" {
if idx >= 0 {
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
m.dirty = true
}
return
}
if idx >= 0 {
l.Tiles[idx].Camera = name
m.dirty = true
return
}
if len(l.Tiles) >= config.MaxTiles {
m.setStatus(fmt.Sprintf("layout is full (%d cameras max)", config.MaxTiles), true)
return
}
l.Tiles = append(l.Tiles, config.Tile{Camera: name, Col: m.edCol, Row: m.edRow, ColSpan: 1, RowSpan: 1})
m.dirty = true
}
func (m *model) clearAt() {
l := m.curLayout()
if idx := m.tileIndexAt(m.edCol, m.edRow); idx >= 0 {
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
m.dirty = true
}
}
// resizeTile grows/shrinks the tile under the cursor by the given span deltas,
// keeping it in-bounds and non-overlapping.
func (m *model) resizeTile(dCol, dRow int) {
idx := m.tileIndexAt(m.edCol, m.edRow)
if idx < 0 {
m.setStatus("no tile here — assign a camera first", true)
return
}
t := &m.curLayout().Tiles[idx]
cs, rs := t.Span()
newCS, newRS := cs+dCol, rs+dRow
if newCS < 1 || newRS < 1 {
return
}
if !m.regionFree(idx, t.Col, t.Row, newCS, newRS) {
m.setStatus("can't resize: would overlap or leave the grid", true)
return
}
t.ColSpan, t.RowSpan = newCS, newRS
m.dirty = true
}
// resizeGrid changes the base grid dimensions, refusing changes that would push
// an existing tile out of bounds.
func (m *model) resizeGrid(dCol, dRow int) {
cols, rows := m.gridDims()
newCols, newRows := cols+dCol, rows+dRow
if newCols < 1 || newRows < 1 || newCols > maxGridDim || newRows > maxGridDim {
return
}
for _, t := range m.curLayout().Tiles {
cs, rs := t.Span()
if t.Col+cs > newCols || t.Row+rs > newRows {
m.setStatus("shrink blocked: a tile would fall outside the grid", true)
return
}
}
m.curLayout().Grid = fmt.Sprintf("%dx%d", newCols, newRows)
if m.edCol > newCols-1 {
m.edCol = newCols - 1
}
if m.edRow > newRows-1 {
m.edRow = newRows - 1
}
m.dirty = true
}

View File

@@ -0,0 +1,62 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
// TestRenderGrid prints a representative editor grid so the layout can be
// eyeballed with `go test -run RenderGrid -v ./internal/tui`.
func TestRenderGrid(t *testing.T) {
lipgloss.SetColorProfile(0) // strip ANSI so the plain grid is readable in logs
cfg := &config.Config{
Layouts: []config.Layout{{
Name: "main-plus",
Grid: "4x3",
Tiles: []config.Tile{
{Camera: "Front Door", Col: 0, Row: 0, ColSpan: 3, RowSpan: 3},
{Camera: "Driveway West", Col: 3, Row: 0},
{Camera: "Back Yard", Col: 3, Row: 1},
{Camera: "G3 Flex", Col: 3, Row: 2},
},
}},
}
m := &model{cfg: cfg, editLayout: 0, edCol: 3, edRow: 0}
t.Log("\n" + m.viewLayoutEdit())
// A long name (>cellW) must clip with an ellipsis, never produce the
// U+FFFD replacement char from slicing a multi-byte rune mid-way.
cols, rows := m.gridDims()
grid := m.renderGrid(cols, rows)
if strings.ContainsRune(grid, '<27>') {
t.Errorf("grid contains a broken multi-byte character:\n%s", grid)
}
}
// TestRenderPicker prints the camera picker (with the live grid and "already
// placed" markers) for a layout that has a long camera name.
func TestRenderPicker(t *testing.T) {
lipgloss.SetColorProfile(0)
cfg := &config.Config{
Cameras: []config.Camera{
{Name: "Driveway West"}, {Name: "Front Door"}, {Name: "Back Yard"}, {Name: "Garage"},
},
Layouts: []config.Layout{{
Name: "quad", Grid: "2x2",
Tiles: []config.Tile{
{Camera: "Driveway West", Col: 0, Row: 0},
{Camera: "Front Door", Col: 1, Row: 0},
},
}},
}
m := &model{cfg: cfg, editLayout: 0, edCol: 0, edRow: 1, cursor: 1, screen: screenCameraPicker}
out := m.viewCameraPicker()
if strings.ContainsRune(out, '<27>') {
t.Errorf("picker contains a broken character:\n%s", out)
}
t.Log("\n" + out)
}

409
internal/tui/tui.go Normal file
View File

@@ -0,0 +1,409 @@
// Package tui is the interactive Bubble Tea configurator. It is meant to be
// run over SSH on the headless Pi: browse cameras, assign them to layout
// slots, pick the active layout, discover cameras from UniFi Protect, and save
// — signalling the running daemon to reload on save.
package tui
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/ipc"
"github.com/lwoodard/rtsp-streamer/internal/protect"
)
type screen int
const (
screenMenu screen = iota
screenCameras
screenLayouts
screenLayoutEdit
screenCameraPicker
screenSetActive
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Padding(0, 1)
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
// helpStyle adapts to light/dark terminals so it stays legible on both.
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "238", Dark: "251"}).MarginTop(1)
// keyStyle accents the key names inside help lines.
keyStyle = lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "26", Dark: "81"})
// usedStyle marks cameras already placed in the current layout.
usedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
// cursorCellStyle highlights the selected grid cell in the layout editor.
cursorCellStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("205"))
)
// hkey formats "<key> <desc>" with the key accented, for help lines.
func hkey(key, desc string) string {
return keyStyle.Render(key) + " " + desc
}
type model struct {
cfgPath string
cfg *config.Config
dirty bool
screen screen
cursor int
editLayout int // index into cfg.Layouts for edit/picker screens
edCol, edRow int // grid-editor cursor position
status string
isError bool
}
// Run loads the config and starts the TUI event loop.
func Run(cfgPath string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu}
_, err = tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
}
func (m *model) Init() tea.Cmd { return nil }
// ---- messages ----
type discoverMsg struct {
cams []protect.Camera
enabled int // count of cameras we turned RTSP on for
err error
}
type savedMsg struct {
err error
reloaded bool
}
// discoverCmd fetches cameras. When enable is non-empty (e.g. "high"), it also
// turns on RTSP in Protect for any camera that lacks an enabled channel.
func (m *model) discoverCmd(enable string) tea.Cmd {
cfg := m.cfg
return func() tea.Msg {
if cfg.Controller.Host == "" {
return discoverMsg{err: fmt.Errorf("controller.host is not set")}
}
pw := cfg.Controller.ResolvePassword()
if pw == "" {
return discoverMsg{err: fmt.Errorf("no controller password available")}
}
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
if err != nil {
return discoverMsg{err: err}
}
ctx := context.Background()
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
return discoverMsg{err: err}
}
cams, err := cl.Cameras(ctx)
if err != nil {
return discoverMsg{err: err}
}
enabled := 0
if enable != "" {
names, _ := cl.EnableMissing(ctx, cams, enable)
enabled = len(names)
}
return discoverMsg{cams: cams, enabled: enabled}
}
}
// ---- update ----
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case discoverMsg:
return m.handleDiscover(msg)
case savedMsg:
if msg.err != nil {
m.setStatus("save failed: "+msg.err.Error(), true)
return m, nil
}
m.dirty = false
if msg.reloaded {
m.setStatus("saved and reloaded the running daemon", false)
} else {
m.setStatus("saved to "+m.cfgPath, false)
}
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.setStatus("discover failed: "+msg.err.Error(), true)
return m, nil
}
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
added, updated, skipped := 0, 0, 0
for _, cam := range msg.cams {
ch := cam.BestEnabledChannel()
if ch == nil {
skipped++
continue
}
url := cl.StreamURL(ch.RTSPAlias)
if ex := m.cameraByID(cam.ID); ex != nil {
ex.Name, ex.RTSP = cam.Name, url
updated++
} else if ex := m.cfg.CameraByName(cam.Name); ex != nil {
ex.ID, ex.RTSP = cam.ID, url
updated++
} else {
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
added++
}
}
m.dirty = true
enabledNote := ""
if msg.enabled > 0 {
enabledNote = fmt.Sprintf(", enabled RTSP on %d", msg.enabled)
}
m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false)
m.screen = screenCameras
m.cursor = 0
return m, nil
}
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
}
switch m.screen {
case screenMenu:
return m.updateMenu(msg)
case screenCameras:
return m.updateCameras(msg)
case screenLayouts:
return m.updateLayouts(msg)
case screenLayoutEdit:
return m.updateLayoutEdit(msg)
case screenCameraPicker:
return m.updateCameraPicker(msg)
case screenSetActive:
return m.updateSetActive(msg)
}
return m, nil
}
// menu -------------------------------------------------------------
const (
menuCameras = iota
menuLayouts
menuSetActive
menuDiscover
menuDiscoverEnable
menuSave
menuQuit
)
var menuItems = []string{
menuCameras: "Cameras",
menuLayouts: "Layouts",
menuSetActive: "Set active layout",
menuDiscover: "Discover from UniFi Protect",
menuDiscoverEnable: "Discover + enable RTSP (high)",
menuSave: "Save",
menuQuit: "Quit",
}
func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
m.moveCursor(-1, len(menuItems))
case "down", "j":
m.moveCursor(1, len(menuItems))
case "enter", "l", " ":
switch m.cursor {
case menuCameras:
m.screen, m.cursor = screenCameras, 0
case menuLayouts:
m.screen, m.cursor = screenLayouts, 0
case menuSetActive:
m.screen, m.cursor = screenSetActive, 0
case menuDiscover:
m.setStatus("discovering…", false)
return m, m.discoverCmd("")
case menuDiscoverEnable:
m.setStatus("discovering and enabling RTSP…", false)
return m, m.discoverCmd("high")
case menuSave:
return m, m.save()
case menuQuit:
return m, m.quit()
}
case "q":
return m, m.quit()
}
return m, nil
}
// cameras ----------------------------------------------------------
func (m *model) updateCameras(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
n := len(m.cfg.Cameras)
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenMenu, 0
case "up", "k":
m.moveCursor(-1, n)
case "down", "j":
m.moveCursor(1, n)
case "d":
if n > 0 {
m.cfg.Cameras[m.cursor].Disabled = !m.cfg.Cameras[m.cursor].Disabled
m.dirty = true
}
case "x":
if n > 0 {
m.cfg.Cameras = append(m.cfg.Cameras[:m.cursor], m.cfg.Cameras[m.cursor+1:]...)
if m.cursor >= len(m.cfg.Cameras) && m.cursor > 0 {
m.cursor--
}
m.dirty = true
}
}
return m, nil
}
// layouts ----------------------------------------------------------
func (m *model) updateLayouts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
n := len(m.cfg.Layouts)
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenMenu, 0
case "up", "k":
m.moveCursor(-1, n)
case "down", "j":
m.moveCursor(1, n)
case "enter", "l":
if n > 0 {
m.editLayout = m.cursor
m.migrateToTiles(m.editLayout)
m.edCol, m.edRow = 0, 0
m.screen, m.cursor = screenLayoutEdit, 0
}
}
return m, nil
}
// (grid-editor update logic lives in gridedit.go)
// camera picker ----------------------------------------------------
// pickerOptions is "(empty)" followed by enabled camera names.
func (m *model) pickerOptions() []string {
opts := []string{"(empty)"}
for _, c := range m.cfg.Cameras {
if c.Disabled {
continue
}
opts = append(opts, c.Name)
}
return opts
}
func (m *model) updateCameraPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
opts := m.pickerOptions()
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenLayoutEdit, 0
case "up", "k":
m.moveCursor(-1, len(opts))
case "down", "j":
m.moveCursor(1, len(opts))
case "enter", "l":
choice := ""
if m.cursor > 0 {
choice = opts[m.cursor]
}
m.assignCamera(choice)
m.screen, m.cursor = screenLayoutEdit, 0
}
return m, nil
}
// set active -------------------------------------------------------
func (m *model) updateSetActive(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
n := len(m.cfg.Layouts)
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenMenu, 0
case "up", "k":
m.moveCursor(-1, n)
case "down", "j":
m.moveCursor(1, n)
case "enter", "l":
if n > 0 {
m.cfg.ActiveLayout = m.cfg.Layouts[m.cursor].Name
m.dirty = true
m.setStatus("active layout set to "+m.cfg.ActiveLayout, false)
m.screen, m.cursor = screenMenu, 0
}
}
return m, nil
}
// helpers ----------------------------------------------------------
func (m *model) moveCursor(delta, n int) {
if n == 0 {
m.cursor = 0
return
}
m.cursor = (m.cursor + delta + n) % n
}
func (m *model) cameraByID(id string) *config.Camera {
if id == "" {
return nil
}
for i := range m.cfg.Cameras {
if m.cfg.Cameras[i].ID == id {
return &m.cfg.Cameras[i]
}
}
return nil
}
func (m *model) setStatus(s string, isErr bool) {
m.status, m.isError = s, isErr
}
func (m *model) save() tea.Cmd {
cfgPath, cfg := m.cfgPath, m.cfg
return func() tea.Msg {
if err := config.Save(cfgPath, cfg); err != nil {
return savedMsg{err: err}
}
// Best-effort: tell a running daemon to reload.
_, err := ipc.Send(ipc.Request{Cmd: "reload"})
return savedMsg{reloaded: err == nil}
}
}
func (m *model) quit() tea.Cmd {
return tea.Quit
}

267
internal/tui/view.go Normal file
View File

@@ -0,0 +1,267 @@
package tui
import (
"fmt"
"strings"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
func (m *model) View() string {
var b strings.Builder
switch m.screen {
case screenMenu:
b.WriteString(m.viewMenu())
case screenCameras:
b.WriteString(m.viewCameras())
case screenLayouts:
b.WriteString(m.viewLayouts())
case screenLayoutEdit:
b.WriteString(m.viewLayoutEdit())
case screenCameraPicker:
b.WriteString(m.viewCameraPicker())
case screenSetActive:
b.WriteString(m.viewSetActive())
}
if m.status != "" {
style := okStyle
if m.isError {
style = errStyle
}
b.WriteString("\n\n" + style.Render(m.status))
}
return b.String()
}
func (m *model) list(title string, rows []string, help string) string {
var b strings.Builder
b.WriteString(titleStyle.Render(title) + "\n\n")
if len(rows) == 0 {
b.WriteString(dimStyle.Render(" (nothing here yet)") + "\n")
}
for i, row := range rows {
cursor := " "
line := row
if i == m.cursor {
cursor = cursorStyle.Render("▸ ")
line = cursorStyle.Render(row)
}
b.WriteString(cursor + line + "\n")
}
b.WriteString(helpStyle.Render(help))
return b.String()
}
func (m *model) viewMenu() string {
dirty := ""
if m.dirty {
dirty = dimStyle.Render(" (unsaved changes)")
}
rows := make([]string, len(menuItems))
copy(rows, menuItems)
rows[menuSave] = fmt.Sprintf("Save%s", dirty)
return m.list("rtsp-streamer configurator", rows,
"↑/↓ move · enter select · q quit")
}
func (m *model) viewCameras() string {
rows := make([]string, 0, len(m.cfg.Cameras))
for _, c := range m.cfg.Cameras {
state := ""
if c.Disabled {
state = dimStyle.Render(" [disabled]")
}
src := dimStyle.Render(truncate(c.RTSP, 48))
rows = append(rows, fmt.Sprintf("%-24s %s%s", c.Name, src, state))
}
return m.list(fmt.Sprintf("Cameras (%d)", len(m.cfg.Cameras)), rows,
"d toggle disabled · x delete · esc back")
}
func (m *model) viewLayouts() string {
rows := make([]string, 0, len(m.cfg.Layouts))
for _, l := range m.cfg.Layouts {
active := ""
if l.Name == m.cfg.ActiveLayout {
active = okStyle.Render(" ●active")
}
rows = append(rows, fmt.Sprintf("%-16s %-6s %d cameras%s", l.Name, l.Grid, len(l.EffectiveTiles()), active))
}
return m.list("Layouts", rows, "enter edit · esc back")
}
// cell dimensions for the ASCII preview.
const (
cellW = 10 // inner width in characters
cellH = 2 // inner height in text rows
)
func (m *model) viewLayoutEdit() string {
l := m.cfg.Layouts[m.editLayout]
cols, rows := m.gridDims()
var b strings.Builder
title := fmt.Sprintf("Edit %q — %dx%d grid, %d/%d cameras", l.Name, cols, rows, len(l.Tiles), 16)
b.WriteString(titleStyle.Render(title) + "\n\n")
b.WriteString(m.renderGrid(cols, rows))
b.WriteString("\n\n")
b.WriteString(strings.Join([]string{
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"),
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
}, "\n"))
return b.String()
}
// renderGrid draws the base grid with tiles, camera labels, continuation marks
// for spanned cells, and a highlighted cursor cell — a live picture of the wall.
func (m *model) renderGrid(cols, rows int) string {
l := m.cfg.Layouts[m.editLayout]
horiz := "+" + strings.Repeat(strings.Repeat("-", cellW)+"+", cols)
var b strings.Builder
for r := 0; r < rows; r++ {
b.WriteString(horiz + "\n")
for line := 0; line < cellH; line++ {
b.WriteString("|")
for c := 0; c < cols; c++ {
b.WriteString(m.renderCell(l, c, r, line) + "|")
}
b.WriteString("\n")
}
}
b.WriteString(horiz)
return b.String()
}
// renderCell returns one text line of one grid cell. It first builds a plain
// string of exactly cellW characters, then styles the whole cell, so ANSI
// escapes never throw off column alignment.
func (m *model) renderCell(l config.Layout, c, r, line int) string {
idx := m.tileIndexAtLayout(l, c, r)
isCursor := c == m.edCol && r == m.edRow
plain := strings.Repeat(" ", cellW)
dim := false
if idx >= 0 {
t := l.Tiles[idx]
isOrigin := c == t.Col && r == t.Row
switch {
case isOrigin && line == 0:
label := t.Camera
if label == "" {
label = "(no cam)"
}
plain = padTo(truncate(label, cellW), cellW)
case isOrigin && line == 1:
cs, rs := t.Span()
if cs > 1 || rs > 1 {
plain = padTo(fmt.Sprintf("%dx%d", cs, rs), cellW)
}
dim = true
default: // continuation cell of a spanned tile
plain = center("·", cellW)
dim = true
}
}
switch {
case isCursor:
return cursorCellStyle.Render(plain)
case dim:
return dimStyle.Render(plain)
default:
return plain
}
}
// tileIndexAtLayout is tileIndexAt against an explicit layout value (used by
// the renderer, which holds a copy).
func (m *model) tileIndexAtLayout(l config.Layout, col, row int) int {
for i, t := range l.Tiles {
cs, rs := t.Span()
if col >= t.Col && col < t.Col+cs && row >= t.Row && row < t.Row+rs {
return i
}
}
return -1
}
func (m *model) viewCameraPicker() string {
l := m.cfg.Layouts[m.editLayout]
cols, rows := m.gridDims()
used := m.usedCameras()
opts := m.pickerOptions()
var b strings.Builder
b.WriteString(titleStyle.Render(fmt.Sprintf("Assign to %q — cell (col %d, row %d)", l.Name, m.edCol, m.edRow)) + "\n\n")
// Show the live grid so you can see what's already placed while choosing.
b.WriteString(m.renderGrid(cols, rows) + "\n\n")
for i, o := range opts {
cursor := " "
var label string
switch {
case i == 0: // the "(empty)" option
label = dimStyle.Render(o)
case used[o]:
label = o + usedStyle.Render(" ● already placed")
default:
label = o
}
if i == m.cursor {
cursor = cursorStyle.Render("▸ ")
if i != 0 && !used[o] {
label = cursorStyle.Render(o)
}
}
b.WriteString(cursor + label + "\n")
}
b.WriteString("\n" + hkey("enter", "choose") + " " + hkey("(empty)", "clears cell") + " " + hkey("esc", "back"))
return b.String()
}
func (m *model) viewSetActive() string {
rows := make([]string, 0, len(m.cfg.Layouts))
for _, l := range m.cfg.Layouts {
mark := ""
if l.Name == m.cfg.ActiveLayout {
mark = okStyle.Render(" ●current")
}
rows = append(rows, fmt.Sprintf("%-16s %s%s", l.Name, l.Grid, mark))
}
return m.list("Set active layout", rows, "enter select · esc back")
}
// The string helpers below count runes, not bytes, so multi-byte characters
// (e.g. the ellipsis) are never sliced mid-character into invalid UTF-8.
// padTo left-justifies s in a field of n columns (single-width runes assumed).
func padTo(s string, n int) string {
r := []rune(s)
if len(r) >= n {
return string(r[:n])
}
return s + strings.Repeat(" ", n-len(r))
}
// center centers s within n columns.
func center(s string, n int) string {
r := []rune(s)
if len(r) >= n {
return string(r[:n])
}
left := (n - len(r)) / 2
return strings.Repeat(" ", left) + s + strings.Repeat(" ", n-len(r)-left)
}
// truncate shortens s to at most n columns, using an ellipsis when it clips.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
if n <= 1 {
return string(r[:n])
}
return string(r[:n-1]) + "…"
}