From 55a8ea4bee02065c7587fd63698fbb4b1f90a16a Mon Sep 17 00:00:00 2001 From: Levi Woodard Date: Wed, 1 Jul 2026 18:21:14 -0500 Subject: [PATCH] adding to git.i0t.app --- .gitignore | 5 + Makefile | 36 ++ README.md | 202 +++++++++ cmd/rtsp-streamer/commands.go | 290 +++++++++++++ cmd/rtsp-streamer/main.go | 58 +++ config.example.yaml | 70 +++ deploy/install.sh | 85 ++++ deploy/profile-snippet.sh | 7 + deploy/sway/config | 35 ++ .../getty@tty1.service.d/autologin.conf | 9 + go.mod | 30 ++ go.sum | 49 +++ internal/compositor/compositor.go | 202 +++++++++ internal/compositor/compositor_test.go | 35 ++ internal/compositor/tiles_test.go | 27 ++ internal/config/config.go | 378 ++++++++++++++++ internal/config/config_test.go | 41 ++ internal/config/tiles_test.go | 58 +++ internal/daemon/daemon.go | 316 ++++++++++++++ internal/ipc/ipc.go | 75 ++++ internal/player/player.go | 233 ++++++++++ internal/protect/protect.go | 358 +++++++++++++++ internal/tui/gridedit.go | 197 +++++++++ internal/tui/render_test.go | 62 +++ internal/tui/tui.go | 409 ++++++++++++++++++ internal/tui/view.go | 267 ++++++++++++ 26 files changed, 3534 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/rtsp-streamer/commands.go create mode 100644 cmd/rtsp-streamer/main.go create mode 100644 config.example.yaml create mode 100755 deploy/install.sh create mode 100644 deploy/profile-snippet.sh create mode 100644 deploy/sway/config create mode 100644 deploy/systemd/getty@tty1.service.d/autologin.conf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/compositor/compositor.go create mode 100644 internal/compositor/compositor_test.go create mode 100644 internal/compositor/tiles_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/tiles_test.go create mode 100644 internal/daemon/daemon.go create mode 100644 internal/ipc/ipc.go create mode 100644 internal/player/player.go create mode 100644 internal/protect/protect.go create mode 100644 internal/tui/gridedit.go create mode 100644 internal/tui/render_test.go create mode 100644 internal/tui/tui.go create mode 100644 internal/tui/view.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a8803d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/bin/ +*.tmp +config.yaml +config.local.yaml +env diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d83e4a6 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +BINARY := rtsp-streamer +PKG := ./cmd/rtsp-streamer +BINDIR := bin +LDFLAGS := -s -w + +.PHONY: all build pi pi32 test vet clean run-tui + +all: build + +## build: compile for the host platform +build: + go build -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINARY) $(PKG) + +## pi: cross-compile for Raspberry Pi 4 running a 64-bit OS (arm64) +pi: + GOOS=linux GOARCH=arm64 go build -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINARY)-arm64 $(PKG) + +## pi32: cross-compile for a 32-bit Pi OS (armv7) +pi32: + GOOS=linux GOARCH=arm GOARM=7 go build -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINARY)-armv7 $(PKG) + +## test: run unit tests +test: + go test ./... + +## vet: run go vet +vet: + go vet ./... + +## run-tui: launch the configurator against a scratch config +run-tui: build + RTSP_STREAMER_CONFIG=/tmp/rtsp-streamer.yaml $(BINDIR)/$(BINARY) tui + +## clean: remove build artifacts +clean: + rm -rf $(BINDIR) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9deb74 --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# rtsp-streamer + +A full-screen RTSP video wall for UniFi Protect cameras, built for a Raspberry +Pi 4 (or any Linux box) plugged into a TV. No Chrome kiosk, no desktop — just +`mpv` tiles under a minimal Wayland compositor, driven by a single Go binary +you configure over SSH from the command line or an interactive TUI. + +## Why this design + +- **One `mpv` process per camera.** A dead or unreachable camera never takes + down the wall — the daemon restarts only the slot that failed, with backoff. + (Contrast with a single ffmpeg/`mpv` mosaic, where one stalled input can + freeze the whole picture.) +- **Compositor tiling, not fullscreen.** A tiny [sway](https://swaywm.org) + session hosts the windows; the daemon positions each one at an exact pixel + rectangle over sway's IPC. No X11, no browser, no GPU compositor tricks. +- **Hardware decode.** `mpv --hwdec=auto-safe` uses the Pi 4's V4L2/DRM H.264 + decoder. +- **Stateful config.** A single hand-editable `config.yaml`, or edit it live + with `rtsp-streamer tui`. Switching layouts is instant and doesn't restart + anything you don't have to. + +``` +Pi 4 (headless, HDMI -> TV) +┌───────────────────────────────────────────────┐ +│ tty1 autologin -> sway -> rtsp-streamer daemon │ +│ ├─ reads active layout -> computes grid rects │ +│ ├─ spawns 1 mpv per slot (--hwdec, IPC sock) │ +│ ├─ swaymsg: float + position each to a cell │ +│ └─ health-monitors mpv, restarts dead streams │ +│ │ +│ control socket ◀── rtsp-streamer layout set … │ +│ ◀── rtsp-streamer tui (over SSH) │ +└───────────────────────────────────────────────┘ + ▲ discover + UniFi Protect controller (login -> bootstrap -> RTSPS URLs) +``` + +## Requirements + +On the display device: + +- `sway` and `mpv` +- optional: a terminal like `foot` for the emergency keybinding + +Debian / Raspberry Pi OS: `sudo apt install sway mpv foot` +Arch: `sudo pacman -S sway mpv foot` + +To build: Go 1.23+ (only on your dev machine; the Pi just needs the binary). + +## Build + +```sh +make build # native binary -> bin/rtsp-streamer +make pi # Raspberry Pi 4, 64-bit OS (arm64) -> bin/rtsp-streamer-arm64 +make pi32 # 32-bit Pi OS (armv7) -> bin/rtsp-streamer-armv7 +make test +``` + +## Configure + +```sh +rtsp-streamer config init # write a starter ~/.config/rtsp-streamer/config.yaml +export RTSP_STREAMER_PASSWORD=… # controller password (kept out of the file) +$EDITOR "$(rtsp-streamer config path)" # set controller.host / username +rtsp-streamer discover # pull cameras + RTSPS URLs from UniFi Protect +rtsp-streamer tui # assign cameras to layout slots, pick active +``` + +See [`config.example.yaml`](config.example.yaml) for the full schema. The +config path is `$XDG_CONFIG_HOME/rtsp-streamer/config.yaml` (i.e. +`~/.config/rtsp-streamer/config.yaml`), overridable with `--config` or +`$RTSP_STREAMER_CONFIG`. + +### Layouts (grid + spanning tiles) + +A layout is a base grid (`COLSxROWS`, up to 8×8) on which each camera is a +**tile** that can span multiple cells. That covers a plain 2×2/3×3/4×4 as well +as security-wall arrangements — one big main view plus a column of small ones, +`2+6`, etc. A layout can show up to **16 cameras** (decoding more than that on a +Pi 4 isn't practical even with substreams). Tiles must stay inside the grid and +may not overlap. See `main-plus` in [`config.example.yaml`](config.example.yaml). + +### The TUI + +`rtsp-streamer tui` is a Bubble Tea configurator meant to be run over SSH: + +- **Cameras** — review discovered cameras, `d` disable, `x` delete. +- **Layouts** — edit an arrangement on a live ASCII grid preview. +- **Set active layout** — choose what the wall shows. +- **Discover** / **Discover + enable RTSP (high)** — pull cameras from Protect. +- **Save** — writes the config and tells a running daemon to reload live. + +In the **layout editor** the grid is drawn live as you edit: + +``` ++----------+----------+----------+----------+ +|Front Door| · | · |Driveway | +|3x3 | · | · | | ++----------+----------+----------+----------+ +| · | · | · |Back Yard | +... +``` + +- move cursor: arrows or `h`/`j`/`k`/`l` +- `enter` assign a camera to the cell (pick `(empty)` to clear) · `c` clear +- resize the tile under the cursor: `L`/`H` wider/narrower, `J`/`K` taller/shorter +- resize the base grid: `]`/`[` add/remove a column, `}`/`{` add/remove a row +- `esc` back + +Elsewhere: arrows / `j`/`k` move, `enter` selects, `esc` goes back, `q` quits. + +## Run + +```sh +rtsp-streamer daemon # normally launched by sway, not by hand +rtsp-streamer status # slot health from the running daemon +rtsp-streamer layout ls # list layouts (* marks active) +rtsp-streamer layout set quad # switch live (persists the choice) +``` + +## Deploy to a Pi (boot straight to the wall) + +```sh +make pi +scp bin/rtsp-streamer-arm64 pi@display:/tmp/ +# on the Pi, put the binary where install.sh expects it and run the installer: +# mv /tmp/rtsp-streamer-arm64 /bin/rtsp-streamer +sudo ./deploy/install.sh kiosk +``` + +`install.sh` installs the binary, drops the kiosk sway config at +`/etc/rtsp-streamer/sway/config`, creates the `kiosk` user, enables tty1 +autologin, and appends a launcher to the user's `~/.bash_profile` that starts +sway on the physical console only (SSH sessions still get a normal shell). +Reboot and the wall comes up. + +Provide the controller password to the daemon by sourcing an env file from the +kiosk user's `~/.bash_profile` **before** the sway launcher, e.g.: + +```sh +# ~/.bash_profile (kiosk user) +[ -f ~/.config/rtsp-streamer/env ] && . ~/.config/rtsp-streamer/env +# >>> rtsp-streamer kiosk launcher >>> (added by install.sh) +``` + +## Enabling RTSP in UniFi Protect + +Protect does not expose RTSP until you turn it on **per camera**. You can do it +by hand (Protect → camera → Settings → Advanced → **RTSP**, enable a channel), +or let rtsp-streamer flip it on for you via the API: + +```sh +rtsp-streamer discover --enable-rtsp=high # enable the High channel where missing +rtsp-streamer discover --enable-rtsp=low # or the Low substream +``` + +In the TUI, the menu item **"Discover + enable RTSP (high)"** does the same. +Enabling preserves each channel's other encoder settings — it only flips the +`isRtspEnabled` flag. Plain `discover` (no flag) never modifies your controller; +it just skips cameras with no RTSP-enabled channel and reports which ones. + +Use `discover --low-res` (or `--enable-rtsp=low`) to prefer substreams for dense +grids (3x3+), which greatly cuts Pi decode load. + +## Troubleshooting + +- **Blank/black tile** — verify the stream directly: + `mpv --rtsp-transport=tcp 'rtsps://HOST:7441/ALIAS?enableSrtp'`. If that + fails, RTSP probably isn't enabled for that camera (see above). +- **`swaymsg not found` / windows not placed** — the daemon must run inside a + sway session (started by the kiosk launcher). Check `echo $SWAYSOCK`. +- **High CPU / dropped frames on a 3x3** — switch those cameras to substreams + (`discover --low-res`) and/or add `player.extra_args: ["--vf=fps=15"]`. +- **`login failed`** — use a *local* Protect account (not a Ubiquiti SSO + login) and confirm `controller.host` reaches the UniFi OS console. + +## Caveats & scope + +- **Unofficial API.** UniFi Protect has no public API; the local endpoints used + here (`/api/auth/login`, `/proxy/protect/api/bootstrap`) are the same stable + ones the Home Assistant integration relies on, but Ubiquiti could change them. + Manual RTSP URLs in `cameras:` work with any camera brand and need no + controller. +- **Linux/Wayland only.** "Other platforms" means other Linux devices (x86 mini + PCs, other SBCs) — anywhere sway + mpv run. It is not a macOS/Windows app. +- **First cut** ships preset grids. Auto-cycling/rotation and free-form + positioning are natural next steps (the daemon already re-tiles on demand). + +## Layout + +``` +cmd/rtsp-streamer/ CLI (cobra): daemon, discover, layout, status, tui, config +internal/config/ YAML load/save/validate, schema, XDG paths +internal/protect/ UniFi Protect client (login, bootstrap, RTSPS URLs) +internal/player/ one supervised mpv per stream, JSON IPC, restart/backoff +internal/compositor/ sway control + NxM grid geometry +internal/daemon/ orchestrator + control socket + health loop +internal/ipc/ control-socket protocol shared by daemon and CLI/TUI +internal/tui/ Bubble Tea configurator +deploy/ sway kiosk config, autologin, install.sh +``` diff --git a/cmd/rtsp-streamer/commands.go b/cmd/rtsp-streamer/commands.go new file mode 100644 index 0000000..250d55c --- /dev/null +++ b/cmd/rtsp-streamer/commands.go @@ -0,0 +1,290 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "sort" + "syscall" + "text/tabwriter" + + "github.com/lwoodard/rtsp-streamer/internal/config" + "github.com/lwoodard/rtsp-streamer/internal/daemon" + "github.com/lwoodard/rtsp-streamer/internal/ipc" + "github.com/lwoodard/rtsp-streamer/internal/protect" + "github.com/lwoodard/rtsp-streamer/internal/tui" + "github.com/spf13/cobra" +) + +// daemonCmd runs the video wall. This is what systemd (via sway) launches. +func daemonCmd() *cobra.Command { + return &cobra.Command{ + Use: "daemon", + Short: "Run the video wall (launched by the kiosk session)", + RunE: func(cmd *cobra.Command, _ []string) error { + log := newLogger() + d, err := daemon.New(cfgPath, log) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // SIGHUP triggers a config reload via the control socket path is + // unnecessary here; the daemon reloads on start and on demand. + return d.Run(ctx) + }, + } +} + +// discoverCmd queries UniFi Protect and merges cameras into the config. +func discoverCmd() *cobra.Command { + var lowRes bool + var enableRTSP string + c := &cobra.Command{ + Use: "discover", + Short: "Discover cameras from UniFi Protect and update the config", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + if cfg.Controller.Host == "" { + return fmt.Errorf("controller.host is not set; edit %s or run `rtsp-streamer config init`", cfgPath) + } + pw := cfg.Controller.ResolvePassword() + if pw == "" { + return fmt.Errorf("no controller password (set controller.password or the env var in controller.password_env)") + } + cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS) + if err != nil { + return err + } + ctx := context.Background() + if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil { + return err + } + cams, err := cl.Cameras(ctx) + if err != nil { + return err + } + + if enableRTSP != "" { + enabled, failed := cl.EnableMissing(ctx, cams, enableRTSP) + for _, name := range enabled { + fmt.Printf(" + enabled RTSP (%s) on %s\n", enableRTSP, name) + } + for name, ferr := range failed { + fmt.Fprintf(os.Stderr, " ! could not enable RTSP on %s: %v\n", name, ferr) + } + } + + added, updated, skipped := 0, 0, 0 + for _, cam := range cams { + ch := cam.BestEnabledChannel() + if lowRes { + ch = cam.LowestEnabledChannel() + } + if ch == nil { + fmt.Fprintf(os.Stderr, " ! %s: no RTSP-enabled channel (enable RTSP in Protect)\n", cam.Name) + skipped++ + continue + } + url := cl.StreamURL(ch.RTSPAlias) + if existing := findByID(cfg, cam.ID); existing != nil { + existing.Name, existing.RTSP = cam.Name, url + updated++ + } else if existing := cfg.CameraByName(cam.Name); existing != nil { + existing.ID, existing.RTSP = cam.ID, url + updated++ + } else { + cfg.Cameras = append(cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url}) + added++ + } + fmt.Printf(" ✓ %-24s %dx%d\n", cam.Name, ch.Width, ch.Height) + } + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + fmt.Printf("\nDiscovered %d cameras (%d added, %d updated, %d skipped). Saved to %s\n", + len(cams), added, updated, skipped, cfgPath) + return nil + }, + } + c.Flags().BoolVar(&lowRes, "low-res", false, "prefer the lowest-resolution substream (good for dense grids)") + c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "enable RTSP in Protect on this channel for cameras that lack it: high|medium|low") + return c +} + +func findByID(cfg *config.Config, id string) *config.Camera { + if id == "" { + return nil + } + for i := range cfg.Cameras { + if cfg.Cameras[i].ID == id { + return &cfg.Cameras[i] + } + } + return nil +} + +// layoutCmd groups layout subcommands. +func layoutCmd() *cobra.Command { + c := &cobra.Command{Use: "layout", Short: "List or switch layouts"} + + ls := &cobra.Command{ + Use: "ls", + Short: "List defined layouts", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + names := make([]string, 0, len(cfg.Layouts)) + for _, l := range cfg.Layouts { + names = append(names, l.Name) + } + sort.Strings(names) + for _, l := range cfg.Layouts { + marker := " " + if l.Name == cfg.ActiveLayout { + marker = "* " + } + fmt.Printf("%s%-16s %-6s %d cameras\n", marker, l.Name, l.Grid, len(l.EffectiveTiles())) + } + return nil + }, + } + + set := &cobra.Command{ + Use: "set ", + Short: "Switch the active layout (live if the daemon is running)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + // Prefer switching the live daemon; it persists the choice too. + if resp, err := ipc.Send(ipc.Request{Cmd: "set-layout", Name: name}); err == nil { + if !resp.OK { + return fmt.Errorf("%s", resp.Error) + } + fmt.Printf("Active layout is now %q (applied live)\n", name) + return nil + } + // Daemon not running: just update the config for next start. + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + if cfg.LayoutByName(name) == nil { + return fmt.Errorf("layout %q not found", name) + } + cfg.ActiveLayout = name + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + fmt.Printf("Active layout set to %q (daemon not running; will apply on next start)\n", name) + return nil + }, + } + + c.AddCommand(ls, set) + return c +} + +// statusCmd asks the running daemon for slot health. +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the running daemon's slot health", + RunE: func(cmd *cobra.Command, _ []string) error { + resp, err := ipc.Send(ipc.Request{Cmd: "status"}) + if err != nil { + return err + } + if !resp.OK { + return fmt.Errorf("%s", resp.Error) + } + fmt.Printf("Active layout: %s\n\n", resp.ActiveLayout) + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "SLOT\tCAMERA\tPID\tRUNNING\tHEALTHY") + for _, s := range resp.Slots { + fmt.Fprintf(w, "%d\t%s\t%d\t%v\t%v\n", s.Slot, s.Camera, s.PID, s.Running, s.Healthy) + } + return w.Flush() + }, + } +} + +// tuiCmd launches the interactive configurator. +func tuiCmd() *cobra.Command { + return &cobra.Command{ + Use: "tui", + Aliases: []string{"config-ui"}, + Short: "Interactive configurator (great over SSH)", + RunE: func(cmd *cobra.Command, _ []string) error { + return tui.Run(cfgPath) + }, + } +} + +// configCmd holds config-file utilities. +func configCmd() *cobra.Command { + c := &cobra.Command{Use: "config", Short: "Config file helpers"} + + initCmd := &cobra.Command{ + Use: "init", + Short: "Write a starter config if none exists", + RunE: func(cmd *cobra.Command, _ []string) error { + if _, err := os.Stat(cfgPath); err == nil { + return fmt.Errorf("config already exists at %s", cfgPath) + } + if err := config.Save(cfgPath, starterConfig()); err != nil { + return err + } + fmt.Printf("Wrote starter config to %s\n", cfgPath) + return nil + }, + } + + pathCmd := &cobra.Command{ + Use: "path", + Short: "Print the resolved config path", + Run: func(cmd *cobra.Command, _ []string) { fmt.Println(cfgPath) }, + } + + editCmd := &cobra.Command{ + Use: "validate", + Short: "Validate the config file", + RunE: func(cmd *cobra.Command, _ []string) error { + if _, err := config.Load(cfgPath); err != nil { + return err + } + fmt.Println("config OK") + return nil + }, + } + + c.AddCommand(initCmd, pathCmd, editCmd) + return c +} + +func starterConfig() *config.Config { + c := &config.Config{ + Controller: config.Controller{ + Host: "192.168.1.1", + Username: "viewer", + PasswordEnv: "RTSP_STREAMER_PASSWORD", + VerifyTLS: false, + }, + Layouts: []config.Layout{ + // Empty layouts to fill in with `rtsp-streamer tui`. + {Name: "quad", Grid: "2x2"}, + {Name: "main-plus", Grid: "4x3"}, + {Name: "single", Grid: "1x1"}, + }, + ActiveLayout: "quad", + } + c.Defaults() + return c +} diff --git a/cmd/rtsp-streamer/main.go b/cmd/rtsp-streamer/main.go new file mode 100644 index 0000000..da42360 --- /dev/null +++ b/cmd/rtsp-streamer/main.go @@ -0,0 +1,58 @@ +// Command rtsp-streamer is a full-screen RTSP video wall for UniFi Protect +// cameras, driven by mpv under a kiosk Wayland compositor and configured from +// the command line or an interactive TUI. +package main + +import ( + "fmt" + "log/slog" + "os" + + "github.com/lwoodard/rtsp-streamer/internal/config" + "github.com/spf13/cobra" +) + +var ( + cfgPath string + logLevel string +) + +func main() { + root := &cobra.Command{ + Use: "rtsp-streamer", + Short: "Full-screen RTSP camera wall for UniFi Protect", + SilenceUsage: true, + SilenceErrors: true, + } + root.PersistentFlags().StringVar(&cfgPath, "config", config.DefaultPath(), "path to config file") + root.PersistentFlags().StringVar(&logLevel, "log", "info", "log level: debug|info|warn|error") + + root.AddCommand( + daemonCmd(), + discoverCmd(), + layoutCmd(), + statusCmd(), + tuiCmd(), + configCmd(), + ) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func newLogger() *slog.Logger { + var lvl slog.Level + switch logLevel { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})) +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..a423237 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,70 @@ +# Example rtsp-streamer config. Copy to ~/.config/rtsp-streamer/config.yaml +# (or point $RTSP_STREAMER_CONFIG at it) and edit. Keep secrets out of it by +# using password_env instead of an inline password. + +controller: + host: 192.168.1.1 # UniFi OS console IP / hostname + username: viewer # a local Protect user with camera view access + password_env: RTSP_STREAMER_PASSWORD # read the password from this env var + verify_tls: false # UniFi ships a self-signed cert + rtsp_port: 7441 # Protect RTSPS port (default) + +# Pin the render resolution, or leave empty to auto-detect from the connected +# output via sway. +display: + width: 1920 + height: 1080 + +player: + hwdec: auto-safe # v4l2m2m/drm on the Pi 4; "no" to force software + profile: low-latency + restart_backoff_seconds: 3 + extra_args: [] # e.g. ["--vf=fps=15"] to cap decode load + +# Camera catalog. Normally populated by `rtsp-streamer discover`; shown here +# filled in by hand for illustration. Layouts reference cameras by `name`. +cameras: + - id: 60a1b2c3d4e5f6 # UniFi Protect camera id (blank for manual entries) + name: Front Door + rtsp: rtsps://192.168.1.1:7441/aBcD1234?enableSrtp + - name: Driveway + rtsp: rtsps://192.168.1.1:7441/eFgH5678?enableSrtp + - name: Back Yard + rtsp: rtsps://192.168.1.1:7441/iJkL9012?enableSrtp + - name: Garage + rtsp: rtsps://192.168.1.1:7441/mNoP3456?enableSrtp + +# Layouts place cameras on a base grid (COLSxROWS). Cameras are "tiles" that +# may span multiple cells, so you can mix a big main view with small side +# tiles. Up to 16 cameras per layout. Edit these interactively with +# `rtsp-streamer tui` (a live ASCII preview shows the arrangement). +layouts: + # Simple even grid: four 1x1 tiles on a 2x2. + - name: quad + grid: 2x2 + tiles: + - {camera: Front Door, col: 0, row: 0} + - {camera: Driveway, col: 1, row: 0} + - {camera: Back Yard, col: 0, row: 1} + - {camera: Garage, col: 1, row: 1} + + # Security-wall style: one big 3x3 main view + a right column of three. + - name: main-plus + grid: 4x3 + tiles: + - {camera: Front Door, col: 0, row: 0, colspan: 3, rowspan: 3} + - {camera: Driveway, col: 3, row: 0} + - {camera: Back Yard, col: 3, row: 1} + - {camera: Garage, col: 3, row: 2} + + - name: front-focus + grid: 1x1 + tiles: + - {camera: Front Door, col: 0, row: 0} + + # The older one-camera-per-cell "slots" form still works and is upgraded to + # tiles automatically when you edit it: + # grid: 3x3 + # slots: [Front Door, Driveway, Back Yard, Garage, "", "", "", "", ""] + +active_layout: quad diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..6cdaf24 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Install rtsp-streamer as a boot-to-wall kiosk. Run as root on the target +# (Raspberry Pi 4 or any Linux with sway + mpv). +# +# sudo ./deploy/install.sh [kiosk-user] +# +# Steps performed: +# * install the rtsp-streamer binary to /usr/local/bin +# * install the kiosk sway config to /etc/rtsp-streamer/sway/config +# * create the kiosk user (if missing) and add it to the video/render/input seats +# * enable tty1 autologin for that user +# * append the sway launcher to the user's ~/.bash_profile +# +# It does NOT install sway/mpv — do that with your distro's package manager: +# Debian/RPi OS: apt install sway mpv foot +# Arch: pacman -S sway mpv foot +set -euo pipefail + +KIOSK_USER="${1:-kiosk}" +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_SRC="${REPO_DIR}/bin/rtsp-streamer" + +if [ "$(id -u)" -ne 0 ]; then + echo "error: run as root (sudo)" >&2 + exit 1 +fi + +if [ ! -x "$BIN_SRC" ]; then + echo "error: ${BIN_SRC} not found. Build it first: make build (native) or make pi (arm64)." >&2 + exit 1 +fi + +echo ">> installing binary to /usr/local/bin/rtsp-streamer" +install -m 0755 "$BIN_SRC" /usr/local/bin/rtsp-streamer + +echo ">> installing sway kiosk config to /etc/rtsp-streamer/sway/config" +install -d /etc/rtsp-streamer/sway +install -m 0644 "${REPO_DIR}/deploy/sway/config" /etc/rtsp-streamer/sway/config + +if ! id "$KIOSK_USER" >/dev/null 2>&1; then + echo ">> creating user ${KIOSK_USER}" + useradd --create-home --shell /bin/bash "$KIOSK_USER" +fi +echo ">> adding ${KIOSK_USER} to video/render/input groups" +for grp in video render input seat; do + getent group "$grp" >/dev/null 2>&1 && usermod -aG "$grp" "$KIOSK_USER" || true +done + +echo ">> enabling tty1 autologin for ${KIOSK_USER}" +install -d /etc/systemd/system/getty@tty1.service.d +sed "s/kiosk/${KIOSK_USER}/" "${REPO_DIR}/deploy/systemd/getty@tty1.service.d/autologin.conf" \ + > /etc/systemd/system/getty@tty1.service.d/autologin.conf +systemctl daemon-reload + +HOME_DIR="$(getent passwd "$KIOSK_USER" | cut -d: -f6)" +PROFILE="${HOME_DIR}/.bash_profile" +MARKER="# >>> rtsp-streamer kiosk launcher >>>" +if ! grep -qF "$MARKER" "$PROFILE" 2>/dev/null; then + echo ">> appending sway launcher to ${PROFILE}" + { + echo "" + echo "$MARKER" + cat "${REPO_DIR}/deploy/profile-snippet.sh" + echo "# <<< rtsp-streamer kiosk launcher <<<" + } >> "$PROFILE" + chown "$KIOSK_USER": "$PROFILE" +else + echo ">> launcher already present in ${PROFILE}, leaving it" +fi + +cat </dev/null +bindsym Mod4+Shift+q exit diff --git a/deploy/systemd/getty@tty1.service.d/autologin.conf b/deploy/systemd/getty@tty1.service.d/autologin.conf new file mode 100644 index 0000000..9590055 --- /dev/null +++ b/deploy/systemd/getty@tty1.service.d/autologin.conf @@ -0,0 +1,9 @@ +# Autologin the kiosk user on tty1. Installed to +# /etc/systemd/system/getty@tty1.service.d/autologin.conf +# +# Change "kiosk" if you use a different user. The shell profile for that user +# (installed by deploy/install.sh) starts sway on tty1, which launches the +# rtsp-streamer daemon. +[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin kiosk --noclear %I $TERM diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..18a9900 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/lwoodard/rtsp-streamer + +go 1.23 + +require ( + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 + github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..77ff45d --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/compositor/compositor.go b/internal/compositor/compositor.go new file mode 100644 index 0000000..13af82e --- /dev/null +++ b/internal/compositor/compositor.go @@ -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`) +} diff --git a/internal/compositor/compositor_test.go b/internal/compositor/compositor_test.go new file mode 100644 index 0000000..523ec62 --- /dev/null +++ b/internal/compositor/compositor_test.go @@ -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) + } + } + } +} diff --git a/internal/compositor/tiles_test.go b/internal/compositor/tiles_test.go new file mode 100644 index 0000000..0e55b96 --- /dev/null +++ b/internal/compositor/tiles_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3b8b2e6 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fff5874 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/config/tiles_test.go b/internal/config/tiles_test.go new file mode 100644 index 0000000..018d74f --- /dev/null +++ b/internal/config/tiles_test.go @@ -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) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..b157512 --- /dev/null +++ b/internal/daemon/daemon.go @@ -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 +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go new file mode 100644 index 0000000..83882d8 --- /dev/null +++ b/internal/ipc/ipc.go @@ -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 +} diff --git a/internal/player/player.go b/internal/player/player.go new file mode 100644 index 0000000..cd6b6e8 --- /dev/null +++ b/internal/player/player.go @@ -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 + } +} diff --git a/internal/protect/protect.go b/internal/protect/protect.go new file mode 100644 index 0000000..e101fa0 --- /dev/null +++ b/internal/protect/protect.go @@ -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/"), 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 +} diff --git a/internal/tui/gridedit.go b/internal/tui/gridedit.go new file mode 100644 index 0000000..396810b --- /dev/null +++ b/internal/tui/gridedit.go @@ -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 +} diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go new file mode 100644 index 0000000..c5b4082 --- /dev/null +++ b/internal/tui/render_test.go @@ -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, '�') { + 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, '�') { + t.Errorf("picker contains a broken character:\n%s", out) + } + t.Log("\n" + out) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..77a6f7b --- /dev/null +++ b/internal/tui/tui.go @@ -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 " " 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 +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..9efe1a1 --- /dev/null +++ b/internal/tui/view.go @@ -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]) + "…" +}