adding to git.i0t.app
This commit is contained in:
202
internal/compositor/compositor.go
Normal file
202
internal/compositor/compositor.go
Normal 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`)
|
||||
}
|
||||
35
internal/compositor/compositor_test.go
Normal file
35
internal/compositor/compositor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
internal/compositor/tiles_test.go
Normal file
27
internal/compositor/tiles_test.go
Normal 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
378
internal/config/config.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// Package config defines the on-disk configuration for rtsp-streamer and
|
||||
// handles loading, validation, and atomic saving. The config is a single
|
||||
// YAML file that is safe to hand-edit or to mutate via the TUI.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config is the root document persisted to disk.
|
||||
type Config struct {
|
||||
// Controller describes how to reach the UniFi Protect controller for
|
||||
// camera discovery. Optional if you only use manually-added cameras.
|
||||
Controller Controller `yaml:"controller"`
|
||||
|
||||
// Display pins the output resolution used for grid geometry math. When
|
||||
// zero, the daemon asks the compositor for the connected output's mode.
|
||||
Display Display `yaml:"display"`
|
||||
|
||||
// Player holds mpv tuning shared by every stream.
|
||||
Player Player `yaml:"player"`
|
||||
|
||||
// Cameras is the discovered/known camera catalog. Populated by
|
||||
// `rtsp-streamer discover` or edited by hand. Layouts reference cameras
|
||||
// by Name.
|
||||
Cameras []Camera `yaml:"cameras"`
|
||||
|
||||
// Layouts are named preset grids.
|
||||
Layouts []Layout `yaml:"layouts"`
|
||||
|
||||
// ActiveLayout is the name of the layout the daemon renders.
|
||||
ActiveLayout string `yaml:"active_layout"`
|
||||
}
|
||||
|
||||
// Controller holds UniFi Protect connection details.
|
||||
type Controller struct {
|
||||
Host string `yaml:"host"` // hostname or IP of the UniFi OS console
|
||||
Username string `yaml:"username"` // local Protect user with camera access
|
||||
// Password is read here only if PasswordEnv is empty. Prefer PasswordEnv
|
||||
// so secrets stay out of the committed config file.
|
||||
Password string `yaml:"password,omitempty"`
|
||||
PasswordEnv string `yaml:"password_env,omitempty"`
|
||||
// VerifyTLS toggles certificate verification. UniFi consoles ship a
|
||||
// self-signed cert by default, so this is false unless you install a
|
||||
// trusted cert.
|
||||
VerifyTLS bool `yaml:"verify_tls"`
|
||||
// RTSPPort is the Protect RTSPS port (7441 on current firmware).
|
||||
RTSPPort int `yaml:"rtsp_port,omitempty"`
|
||||
}
|
||||
|
||||
// ResolvePassword returns the effective password, preferring the env var.
|
||||
func (c Controller) ResolvePassword() string {
|
||||
if c.PasswordEnv != "" {
|
||||
if v := os.Getenv(c.PasswordEnv); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return c.Password
|
||||
}
|
||||
|
||||
// Display pins the render resolution.
|
||||
type Display struct {
|
||||
Width int `yaml:"width,omitempty"`
|
||||
Height int `yaml:"height,omitempty"`
|
||||
}
|
||||
|
||||
// Player is shared mpv configuration.
|
||||
type Player struct {
|
||||
// HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m",
|
||||
// "drm", "no"). "auto-safe" is a good default on the Pi 4.
|
||||
HWDec string `yaml:"hwdec"`
|
||||
// Profile applies an mpv profile; "low-latency" trims buffering for live
|
||||
// feeds. Empty disables it.
|
||||
Profile string `yaml:"profile"`
|
||||
// ExtraArgs are appended verbatim to every mpv invocation.
|
||||
ExtraArgs []string `yaml:"extra_args,omitempty"`
|
||||
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
||||
// that exited or stalled.
|
||||
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Camera is one known RTSP source.
|
||||
type Camera struct {
|
||||
// ID is the UniFi Protect camera id, when discovered. Blank for manual
|
||||
// entries.
|
||||
ID string `yaml:"id,omitempty"`
|
||||
// Name is the human label and the key layouts reference. Must be unique.
|
||||
Name string `yaml:"name"`
|
||||
// RTSP is the fully-resolved stream URL.
|
||||
RTSP string `yaml:"rtsp"`
|
||||
// Disabled hides the camera from selection without deleting it.
|
||||
Disabled bool `yaml:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// MaxTiles caps how many simultaneous streams a layout may show. Decoding
|
||||
// more than this on a Pi 4 is impractical even with substreams.
|
||||
const MaxTiles = 16
|
||||
|
||||
// Layout is a named arrangement on a base grid. Cameras are placed as Tiles
|
||||
// that may span multiple grid cells (a big main view plus small side tiles,
|
||||
// security-wall style). The older Slots form (one camera per cell, row-major)
|
||||
// is still accepted and is transparently upgraded to tiles.
|
||||
type Layout struct {
|
||||
Name string `yaml:"name"`
|
||||
// Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and
|
||||
// sized in these cells.
|
||||
Grid string `yaml:"grid"`
|
||||
// Tiles is the placement model. Preferred over Slots.
|
||||
Tiles []Tile `yaml:"tiles,omitempty"`
|
||||
// Slots is the legacy one-camera-per-cell model (row-major). Kept for
|
||||
// backward compatibility; EffectiveTiles converts it to tiles.
|
||||
Slots []string `yaml:"slots,omitempty"`
|
||||
}
|
||||
|
||||
// Tile places one camera at a rectangular region of the base grid.
|
||||
type Tile struct {
|
||||
Camera string `yaml:"camera"`
|
||||
Col int `yaml:"col"`
|
||||
Row int `yaml:"row"`
|
||||
ColSpan int `yaml:"colspan,omitempty"` // defaults to 1
|
||||
RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1
|
||||
}
|
||||
|
||||
// Span returns the tile's spans with zero values normalized to 1.
|
||||
func (t Tile) Span() (colspan, rowspan int) {
|
||||
colspan, rowspan = t.ColSpan, t.RowSpan
|
||||
if colspan < 1 {
|
||||
colspan = 1
|
||||
}
|
||||
if rowspan < 1 {
|
||||
rowspan = 1
|
||||
}
|
||||
return colspan, rowspan
|
||||
}
|
||||
|
||||
// EffectiveTiles returns the layout's tiles, normalizing spans and upgrading a
|
||||
// legacy Slots list to 1x1 tiles when Tiles is empty.
|
||||
func (l Layout) EffectiveTiles() []Tile {
|
||||
if len(l.Tiles) > 0 {
|
||||
out := make([]Tile, len(l.Tiles))
|
||||
for i, t := range l.Tiles {
|
||||
cs, rs := t.Span()
|
||||
t.ColSpan, t.RowSpan = cs, rs
|
||||
out[i] = t
|
||||
}
|
||||
return out
|
||||
}
|
||||
cols, _, err := l.Dimensions()
|
||||
if err != nil || cols == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []Tile
|
||||
for i, cam := range l.Slots {
|
||||
if cam == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Tile{Camera: cam, Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Dimensions parses Grid into cols, rows.
|
||||
func (l Layout) Dimensions() (cols, rows int, err error) {
|
||||
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(l.Grid)), "x", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("layout %q: grid %q must look like COLSxROWS", l.Name, l.Grid)
|
||||
}
|
||||
cols, err = strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || cols < 1 {
|
||||
return 0, 0, fmt.Errorf("layout %q: bad column count in grid %q", l.Name, l.Grid)
|
||||
}
|
||||
rows, err = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || rows < 1 {
|
||||
return 0, 0, fmt.Errorf("layout %q: bad row count in grid %q", l.Name, l.Grid)
|
||||
}
|
||||
return cols, rows, nil
|
||||
}
|
||||
|
||||
// Capacity is the number of cells in the grid.
|
||||
func (l Layout) Capacity() int {
|
||||
cols, rows, err := l.Dimensions()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return cols * rows
|
||||
}
|
||||
|
||||
// CameraByName returns the named camera, or nil if absent.
|
||||
func (c *Config) CameraByName(name string) *Camera {
|
||||
for i := range c.Cameras {
|
||||
if c.Cameras[i].Name == name {
|
||||
return &c.Cameras[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LayoutByName returns the named layout, or nil if absent.
|
||||
func (c *Config) LayoutByName(name string) *Layout {
|
||||
for i := range c.Layouts {
|
||||
if c.Layouts[i].Name == name {
|
||||
return &c.Layouts[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Active returns the currently selected layout, or nil.
|
||||
func (c *Config) Active() *Layout {
|
||||
if c.ActiveLayout == "" {
|
||||
return nil
|
||||
}
|
||||
return c.LayoutByName(c.ActiveLayout)
|
||||
}
|
||||
|
||||
// Defaults fills in sensible zero-value replacements. Called after load.
|
||||
func (c *Config) Defaults() {
|
||||
if c.Controller.RTSPPort == 0 {
|
||||
c.Controller.RTSPPort = 7441
|
||||
}
|
||||
if c.Player.HWDec == "" {
|
||||
c.Player.HWDec = "auto-safe"
|
||||
}
|
||||
if c.Player.RestartBackoffSeconds == 0 {
|
||||
c.Player.RestartBackoffSeconds = 3
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks referential integrity and returns the first problem found.
|
||||
func (c *Config) Validate() error {
|
||||
seen := map[string]bool{}
|
||||
for _, cam := range c.Cameras {
|
||||
if cam.Name == "" {
|
||||
return fmt.Errorf("a camera is missing a name")
|
||||
}
|
||||
if seen[cam.Name] {
|
||||
return fmt.Errorf("duplicate camera name %q", cam.Name)
|
||||
}
|
||||
seen[cam.Name] = true
|
||||
}
|
||||
|
||||
layoutNames := map[string]bool{}
|
||||
for _, l := range c.Layouts {
|
||||
if l.Name == "" {
|
||||
return fmt.Errorf("a layout is missing a name")
|
||||
}
|
||||
if layoutNames[l.Name] {
|
||||
return fmt.Errorf("duplicate layout name %q", l.Name)
|
||||
}
|
||||
layoutNames[l.Name] = true
|
||||
|
||||
cols, rows, err := l.Dimensions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(l.Tiles) > 0 {
|
||||
if err := validateTiles(l, cols, rows, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if len(l.Slots) > cols*rows {
|
||||
return fmt.Errorf("layout %q: %d slots exceed grid capacity %d", l.Name, len(l.Slots), cols*rows)
|
||||
}
|
||||
for _, slot := range l.Slots {
|
||||
if slot != "" && !seen[slot] {
|
||||
return fmt.Errorf("layout %q references unknown camera %q", l.Name, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.ActiveLayout != "" && !layoutNames[c.ActiveLayout] {
|
||||
return fmt.Errorf("active_layout %q is not a defined layout", c.ActiveLayout)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTiles checks a tile-based layout: in-bounds, no overlap, known
|
||||
// cameras, and within the MaxTiles cap.
|
||||
func validateTiles(l Layout, cols, rows int, knownCameras map[string]bool) error {
|
||||
if len(l.Tiles) > MaxTiles {
|
||||
return fmt.Errorf("layout %q: %d tiles exceed the %d-camera limit", l.Name, len(l.Tiles), MaxTiles)
|
||||
}
|
||||
occupied := make([]bool, cols*rows)
|
||||
for _, t := range l.Tiles {
|
||||
cs, rs := t.Span()
|
||||
if t.Col < 0 || t.Row < 0 || t.Col+cs > cols || t.Row+rs > rows {
|
||||
return fmt.Errorf("layout %q: tile %q at (%d,%d)+%dx%d falls outside the %dx%d grid",
|
||||
l.Name, t.Camera, t.Col, t.Row, cs, rs, cols, rows)
|
||||
}
|
||||
if t.Camera != "" && !knownCameras[t.Camera] {
|
||||
return fmt.Errorf("layout %q references unknown camera %q", l.Name, t.Camera)
|
||||
}
|
||||
for r := t.Row; r < t.Row+rs; r++ {
|
||||
for cc := t.Col; cc < t.Col+cs; cc++ {
|
||||
idx := r*cols + cc
|
||||
if occupied[idx] {
|
||||
return fmt.Errorf("layout %q: tiles overlap at cell (col %d, row %d)", l.Name, cc, r)
|
||||
}
|
||||
occupied[idx] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultPath returns the XDG config path, honoring $RTSP_STREAMER_CONFIG.
|
||||
func DefaultPath() string {
|
||||
if p := os.Getenv("RTSP_STREAMER_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
base = filepath.Join(home, ".config")
|
||||
}
|
||||
}
|
||||
return filepath.Join(base, "rtsp-streamer", "config.yaml")
|
||||
}
|
||||
|
||||
// Load reads and validates the config at path. A missing file yields a
|
||||
// zero-value config with defaults applied (not an error), so first-run tools
|
||||
// can start from an empty state.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c := &Config{}
|
||||
c.Defaults()
|
||||
return c, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(data, &c); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
c.Defaults()
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config %s: %w", path, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Save writes the config atomically (temp file + rename) so a crash mid-write
|
||||
// never truncates the live config.
|
||||
func Save(path string, c *Config) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return fmt.Errorf("refusing to save invalid config: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.yaml.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op if rename succeeded
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
41
internal/config/config_test.go
Normal file
41
internal/config/config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
58
internal/config/tiles_test.go
Normal file
58
internal/config/tiles_test.go
Normal 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
316
internal/daemon/daemon.go
Normal 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
75
internal/ipc/ipc.go
Normal 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
233
internal/player/player.go
Normal 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
358
internal/protect/protect.go
Normal 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
197
internal/tui/gridedit.go
Normal 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
|
||||
}
|
||||
62
internal/tui/render_test.go
Normal file
62
internal/tui/render_test.go
Normal 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
409
internal/tui/tui.go
Normal 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
267
internal/tui/view.go
Normal 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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user