adding to git.i0t.app
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/bin/
|
||||||
|
*.tmp
|
||||||
|
config.yaml
|
||||||
|
config.local.yaml
|
||||||
|
env
|
||||||
36
Makefile
Normal file
36
Makefile
Normal file
@@ -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)
|
||||||
202
README.md
Normal file
202
README.md
Normal file
@@ -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 <repo>/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
|
||||||
|
```
|
||||||
290
cmd/rtsp-streamer/commands.go
Normal file
290
cmd/rtsp-streamer/commands.go
Normal file
@@ -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 <name>",
|
||||||
|
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
|
||||||
|
}
|
||||||
58
cmd/rtsp-streamer/main.go
Normal file
58
cmd/rtsp-streamer/main.go
Normal file
@@ -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}))
|
||||||
|
}
|
||||||
70
config.example.yaml
Normal file
70
config.example.yaml
Normal file
@@ -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
|
||||||
85
deploy/install.sh
Executable file
85
deploy/install.sh
Executable file
@@ -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 <<EOF
|
||||||
|
|
||||||
|
Done. Next steps:
|
||||||
|
1. Put a config at ${HOME_DIR}/.config/rtsp-streamer/config.yaml
|
||||||
|
(as ${KIOSK_USER}: rtsp-streamer config init then edit it, or run
|
||||||
|
rtsp-streamer tui).
|
||||||
|
2. Export the controller password for the daemon. Easiest: create
|
||||||
|
${HOME_DIR}/.config/rtsp-streamer/env with RTSP_STREAMER_PASSWORD=...
|
||||||
|
and source it from ~/.bash_profile before sway starts.
|
||||||
|
3. Discover cameras: sudo -u ${KIOSK_USER} rtsp-streamer discover
|
||||||
|
4. Reboot, or switch to tty1, to bring up the wall.
|
||||||
|
|
||||||
|
Make sure 'sway' and 'mpv' are installed. Verify a stream first with:
|
||||||
|
mpv --rtsp-transport=tcp 'rtsps://HOST:7441/ALIAS?enableSrtp'
|
||||||
|
EOF
|
||||||
7
deploy/profile-snippet.sh
Normal file
7
deploy/profile-snippet.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# rtsp-streamer kiosk launcher — appended to the kiosk user's ~/.bash_profile
|
||||||
|
# by deploy/install.sh. Starts sway (and thus the video wall) only on the
|
||||||
|
# physical console tty1, so SSH logins get a normal shell.
|
||||||
|
if [ "$(tty)" = "/dev/tty1" ] && [ -z "$WAYLAND_DISPLAY" ] && [ -z "$SSH_CONNECTION" ]; then
|
||||||
|
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
|
||||||
|
exec sway -c /etc/rtsp-streamer/sway/config
|
||||||
|
fi
|
||||||
35
deploy/sway/config
Normal file
35
deploy/sway/config
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# rtsp-streamer kiosk sway config
|
||||||
|
#
|
||||||
|
# Installed to /etc/rtsp-streamer/sway/config. sway is started on tty1 by the
|
||||||
|
# autologin shell (see deploy/install.sh) and this config launches the video
|
||||||
|
# wall daemon. The daemon inherits WAYLAND_DISPLAY and SWAYSOCK from sway, so
|
||||||
|
# it can position mpv windows over the sway IPC.
|
||||||
|
|
||||||
|
### Appearance — a black, chromeless canvas for video tiles ###
|
||||||
|
output * bg #000000 solid_color
|
||||||
|
default_border none
|
||||||
|
default_floating_border none
|
||||||
|
titlebar_border_thickness 0
|
||||||
|
gaps inner 0
|
||||||
|
gaps outer 0
|
||||||
|
smart_borders no
|
||||||
|
focus_follows_mouse no
|
||||||
|
|
||||||
|
# Hide the pointer almost immediately; there is no keyboard/mouse on the TV.
|
||||||
|
seat * hide_cursor 1
|
||||||
|
|
||||||
|
# Every mpv window should map borderless and floating so the daemon can place
|
||||||
|
# it at an exact pixel rectangle. The daemon also sets this, belt-and-braces.
|
||||||
|
for_window [app_id="mpv"] floating enable, border none
|
||||||
|
|
||||||
|
### Prevent the TV from blanking ###
|
||||||
|
# We do NOT run swayidle, so sway will not blank the output on its own. If your
|
||||||
|
# panel still sleeps, uncomment a keep-alive loop in a systemd timer instead.
|
||||||
|
|
||||||
|
### Launch the wall ###
|
||||||
|
exec rtsp-streamer daemon --log info
|
||||||
|
|
||||||
|
### Emergency escape hatches (handy while setting up over a keyboard) ###
|
||||||
|
# Super+Return opens a terminal if one is installed; Super+Shift+Q exits sway.
|
||||||
|
bindsym Mod4+Return exec (foot || alacritty || xterm) 2>/dev/null
|
||||||
|
bindsym Mod4+Shift+q exit
|
||||||
9
deploy/systemd/getty@tty1.service.d/autologin.conf
Normal file
9
deploy/systemd/getty@tty1.service.d/autologin.conf
Normal file
@@ -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
|
||||||
30
go.mod
Normal file
30
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
49
go.sum
Normal file
49
go.sum
Normal file
@@ -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=
|
||||||
202
internal/compositor/compositor.go
Normal file
202
internal/compositor/compositor.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// Package compositor drives a running sway session to tile mpv windows into a
|
||||||
|
// grid. The daemon does NOT launch sway; the standard kiosk pattern is for
|
||||||
|
// sway (started at boot via autologin) to exec the daemon, so SWAYSOCK and
|
||||||
|
// WAYLAND_DISPLAY are inherited. This package shells out to swaymsg, which is
|
||||||
|
// always present alongside sway and speaks the sway IPC for us.
|
||||||
|
package compositor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rect is a pixel rectangle on the output.
|
||||||
|
type Rect struct {
|
||||||
|
X, Y, W, H int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridRects splits a WxH area into cols*rows cells in row-major order. The
|
||||||
|
// final column and row absorb any rounding remainder so there are no gaps.
|
||||||
|
func GridRects(width, height, cols, rows int) []Rect {
|
||||||
|
rects := make([]Rect, 0, cols*rows)
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
x := c * width / cols
|
||||||
|
y := r * height / rows
|
||||||
|
// Right/bottom edge computed from the next cell boundary to avoid
|
||||||
|
// cumulative rounding gaps.
|
||||||
|
x2 := (c + 1) * width / cols
|
||||||
|
y2 := (r + 1) * height / rows
|
||||||
|
if c == cols-1 {
|
||||||
|
x2 = width
|
||||||
|
}
|
||||||
|
if r == rows-1 {
|
||||||
|
y2 = height
|
||||||
|
}
|
||||||
|
rects = append(rects, Rect{X: x, Y: y, W: x2 - x, H: y2 - y})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rects
|
||||||
|
}
|
||||||
|
|
||||||
|
// TileRect computes the pixel rectangle for a tile spanning (colspan x rowspan)
|
||||||
|
// cells starting at (col,row) in a cols x rows base grid over a WxH output.
|
||||||
|
// Boundaries are computed from cell edges so adjacent tiles meet exactly and
|
||||||
|
// the far edges reach the full width/height with no rounding gaps.
|
||||||
|
func TileRect(width, height, cols, rows, col, row, colspan, rowspan int) Rect {
|
||||||
|
x := col * width / cols
|
||||||
|
y := row * height / rows
|
||||||
|
x2 := (col + colspan) * width / cols
|
||||||
|
y2 := (row + rowspan) * height / rows
|
||||||
|
if col+colspan >= cols {
|
||||||
|
x2 = width
|
||||||
|
}
|
||||||
|
if row+rowspan >= rows {
|
||||||
|
y2 = height
|
||||||
|
}
|
||||||
|
return Rect{X: x, Y: y, W: x2 - x, H: y2 - y}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client talks to sway via swaymsg.
|
||||||
|
type Client struct {
|
||||||
|
bin string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a compositor client. It verifies swaymsg is on PATH.
|
||||||
|
func New() (*Client, error) {
|
||||||
|
bin, err := exec.LookPath("swaymsg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("swaymsg not found on PATH: %w", err)
|
||||||
|
}
|
||||||
|
return &Client{bin: bin}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes a raw sway command string.
|
||||||
|
func (c *Client) run(ctx context.Context, command string) error {
|
||||||
|
out, err := exec.CommandContext(ctx, c.bin, command).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("swaymsg %q: %w: %s", command, err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output describes a connected display from `swaymsg -t get_outputs`.
|
||||||
|
type Output struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Focused bool `json:"focused"`
|
||||||
|
CurrentMode struct {
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
} `json:"current_mode"`
|
||||||
|
Rect Rect `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrimaryOutput returns the focused active output (or the first active one),
|
||||||
|
// used to auto-detect resolution when the config doesn't pin it.
|
||||||
|
func (c *Client) PrimaryOutput(ctx context.Context) (*Output, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_outputs", "-r").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get_outputs: %w", err)
|
||||||
|
}
|
||||||
|
var outputs []Output
|
||||||
|
if err := json.Unmarshal(out, &outputs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var first *Output
|
||||||
|
for i := range outputs {
|
||||||
|
o := &outputs[i]
|
||||||
|
if !o.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first == nil {
|
||||||
|
first = o
|
||||||
|
}
|
||||||
|
if o.Focused {
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first == nil {
|
||||||
|
return nil, fmt.Errorf("no active output found")
|
||||||
|
}
|
||||||
|
return first, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeNode is the recursive shape of `swaymsg -t get_tree`.
|
||||||
|
type treeNode struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
Nodes []treeNode `json:"nodes"`
|
||||||
|
Float []treeNode `json:"floating_nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *treeNode) walk(fn func(*treeNode)) {
|
||||||
|
fn(n)
|
||||||
|
for i := range n.Nodes {
|
||||||
|
n.Nodes[i].walk(fn)
|
||||||
|
}
|
||||||
|
for i := range n.Float {
|
||||||
|
n.Float[i].walk(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPID reports whether a window with the given pid currently exists.
|
||||||
|
func (c *Client) hasPID(ctx context.Context, pid int) (bool, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get_tree: %w", err)
|
||||||
|
}
|
||||||
|
var root treeNode
|
||||||
|
if err := json.Unmarshal(out, &root); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
root.walk(func(n *treeNode) {
|
||||||
|
if n.PID == pid && (n.AppID != "" || n.Name != "") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForWindow blocks until a window owned by pid maps, or ctx/timeout fires.
|
||||||
|
func (c *Client) WaitForWindow(ctx context.Context, pid int, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
ticker := time.NewTicker(150 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
ok, err := c.hasPID(ctx, pid)
|
||||||
|
if err == nil && ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("window for pid %d did not appear within %s", pid, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place floats and positions the window owned by pid at the given rect. Using
|
||||||
|
// the pid criterion means we never depend on window titles or app-ids.
|
||||||
|
func (c *Client) Place(ctx context.Context, pid int, r Rect) error {
|
||||||
|
cmd := fmt.Sprintf(
|
||||||
|
"[pid=%d] floating enable, border none, move absolute position %d %d, resize set %d %d",
|
||||||
|
pid, r.X, r.Y, r.W, r.H,
|
||||||
|
)
|
||||||
|
return c.run(ctx, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareForMPV installs global rules so every mpv window is borderless and
|
||||||
|
// floating the moment it maps, avoiding a flash of tiled/bordered video before
|
||||||
|
// Place runs. Safe to call repeatedly.
|
||||||
|
func (c *Client) PrepareForMPV(ctx context.Context) error {
|
||||||
|
return c.run(ctx, `for_window [app_id="mpv"] floating enable, border none`)
|
||||||
|
}
|
||||||
35
internal/compositor/compositor_test.go
Normal file
35
internal/compositor/compositor_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package compositor
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGridRectsCoverExactly(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
w, h, cols, rows int
|
||||||
|
}{
|
||||||
|
{1920, 1080, 2, 2},
|
||||||
|
{1920, 1080, 3, 3},
|
||||||
|
{1920, 1080, 1, 1},
|
||||||
|
{1366, 768, 3, 2}, // odd dimensions to exercise remainder handling
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
rects := GridRects(tc.w, tc.h, tc.cols, tc.rows)
|
||||||
|
if len(rects) != tc.cols*tc.rows {
|
||||||
|
t.Fatalf("%dx%d grid %dx%d: got %d rects", tc.w, tc.h, tc.cols, tc.rows, len(rects))
|
||||||
|
}
|
||||||
|
// Sum of areas must equal the whole output with no gaps or overlap on
|
||||||
|
// the axis boundaries: verify the last column reaches the right edge
|
||||||
|
// and the last row reaches the bottom edge.
|
||||||
|
last := rects[len(rects)-1]
|
||||||
|
if last.X+last.W != tc.w {
|
||||||
|
t.Errorf("%dx%d grid %dx%d: last cell right edge %d != %d", tc.w, tc.h, tc.cols, tc.rows, last.X+last.W, tc.w)
|
||||||
|
}
|
||||||
|
if last.Y+last.H != tc.h {
|
||||||
|
t.Errorf("%dx%d grid %dx%d: last cell bottom edge %d != %d", tc.w, tc.h, tc.cols, tc.rows, last.Y+last.H, tc.h)
|
||||||
|
}
|
||||||
|
for i, r := range rects {
|
||||||
|
if r.W <= 0 || r.H <= 0 {
|
||||||
|
t.Errorf("cell %d has non-positive size %+v", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
internal/compositor/tiles_test.go
Normal file
27
internal/compositor/tiles_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package compositor
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTileRect(t *testing.T) {
|
||||||
|
W, H := 1920, 1080
|
||||||
|
// A 1x1 tile at (0,0) in a 4x4 grid.
|
||||||
|
got := TileRect(W, H, 4, 4, 0, 0, 1, 1)
|
||||||
|
if got.W != W/4 || got.H != H/4 || got.X != 0 || got.Y != 0 {
|
||||||
|
t.Errorf("1x1 top-left: got %+v", got)
|
||||||
|
}
|
||||||
|
// A tile spanning the full width/height must reach the exact edges.
|
||||||
|
full := TileRect(W, H, 4, 4, 0, 0, 4, 4)
|
||||||
|
if full.X != 0 || full.Y != 0 || full.W != W || full.H != H {
|
||||||
|
t.Errorf("full-span: got %+v want full frame", full)
|
||||||
|
}
|
||||||
|
// A 3x3 main tile plus a right column: the main reaches 3/4 width, the
|
||||||
|
// side tile fills the remainder to the exact right edge.
|
||||||
|
main := TileRect(W, H, 4, 4, 0, 0, 3, 4)
|
||||||
|
side := TileRect(W, H, 4, 4, 3, 0, 1, 1)
|
||||||
|
if main.X+main.W != side.X {
|
||||||
|
t.Errorf("main right edge %d should meet side left edge %d", main.X+main.W, side.X)
|
||||||
|
}
|
||||||
|
if side.X+side.W != W {
|
||||||
|
t.Errorf("side tile right edge %d should reach %d", side.X+side.W, W)
|
||||||
|
}
|
||||||
|
}
|
||||||
378
internal/config/config.go
Normal file
378
internal/config/config.go
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
// Package config defines the on-disk configuration for rtsp-streamer and
|
||||||
|
// handles loading, validation, and atomic saving. The config is a single
|
||||||
|
// YAML file that is safe to hand-edit or to mutate via the TUI.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the root document persisted to disk.
|
||||||
|
type Config struct {
|
||||||
|
// Controller describes how to reach the UniFi Protect controller for
|
||||||
|
// camera discovery. Optional if you only use manually-added cameras.
|
||||||
|
Controller Controller `yaml:"controller"`
|
||||||
|
|
||||||
|
// Display pins the output resolution used for grid geometry math. When
|
||||||
|
// zero, the daemon asks the compositor for the connected output's mode.
|
||||||
|
Display Display `yaml:"display"`
|
||||||
|
|
||||||
|
// Player holds mpv tuning shared by every stream.
|
||||||
|
Player Player `yaml:"player"`
|
||||||
|
|
||||||
|
// Cameras is the discovered/known camera catalog. Populated by
|
||||||
|
// `rtsp-streamer discover` or edited by hand. Layouts reference cameras
|
||||||
|
// by Name.
|
||||||
|
Cameras []Camera `yaml:"cameras"`
|
||||||
|
|
||||||
|
// Layouts are named preset grids.
|
||||||
|
Layouts []Layout `yaml:"layouts"`
|
||||||
|
|
||||||
|
// ActiveLayout is the name of the layout the daemon renders.
|
||||||
|
ActiveLayout string `yaml:"active_layout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller holds UniFi Protect connection details.
|
||||||
|
type Controller struct {
|
||||||
|
Host string `yaml:"host"` // hostname or IP of the UniFi OS console
|
||||||
|
Username string `yaml:"username"` // local Protect user with camera access
|
||||||
|
// Password is read here only if PasswordEnv is empty. Prefer PasswordEnv
|
||||||
|
// so secrets stay out of the committed config file.
|
||||||
|
Password string `yaml:"password,omitempty"`
|
||||||
|
PasswordEnv string `yaml:"password_env,omitempty"`
|
||||||
|
// VerifyTLS toggles certificate verification. UniFi consoles ship a
|
||||||
|
// self-signed cert by default, so this is false unless you install a
|
||||||
|
// trusted cert.
|
||||||
|
VerifyTLS bool `yaml:"verify_tls"`
|
||||||
|
// RTSPPort is the Protect RTSPS port (7441 on current firmware).
|
||||||
|
RTSPPort int `yaml:"rtsp_port,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePassword returns the effective password, preferring the env var.
|
||||||
|
func (c Controller) ResolvePassword() string {
|
||||||
|
if c.PasswordEnv != "" {
|
||||||
|
if v := os.Getenv(c.PasswordEnv); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display pins the render resolution.
|
||||||
|
type Display struct {
|
||||||
|
Width int `yaml:"width,omitempty"`
|
||||||
|
Height int `yaml:"height,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player is shared mpv configuration.
|
||||||
|
type Player struct {
|
||||||
|
// HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m",
|
||||||
|
// "drm", "no"). "auto-safe" is a good default on the Pi 4.
|
||||||
|
HWDec string `yaml:"hwdec"`
|
||||||
|
// Profile applies an mpv profile; "low-latency" trims buffering for live
|
||||||
|
// feeds. Empty disables it.
|
||||||
|
Profile string `yaml:"profile"`
|
||||||
|
// ExtraArgs are appended verbatim to every mpv invocation.
|
||||||
|
ExtraArgs []string `yaml:"extra_args,omitempty"`
|
||||||
|
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
||||||
|
// that exited or stalled.
|
||||||
|
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera is one known RTSP source.
|
||||||
|
type Camera struct {
|
||||||
|
// ID is the UniFi Protect camera id, when discovered. Blank for manual
|
||||||
|
// entries.
|
||||||
|
ID string `yaml:"id,omitempty"`
|
||||||
|
// Name is the human label and the key layouts reference. Must be unique.
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
// RTSP is the fully-resolved stream URL.
|
||||||
|
RTSP string `yaml:"rtsp"`
|
||||||
|
// Disabled hides the camera from selection without deleting it.
|
||||||
|
Disabled bool `yaml:"disabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxTiles caps how many simultaneous streams a layout may show. Decoding
|
||||||
|
// more than this on a Pi 4 is impractical even with substreams.
|
||||||
|
const MaxTiles = 16
|
||||||
|
|
||||||
|
// Layout is a named arrangement on a base grid. Cameras are placed as Tiles
|
||||||
|
// that may span multiple grid cells (a big main view plus small side tiles,
|
||||||
|
// security-wall style). The older Slots form (one camera per cell, row-major)
|
||||||
|
// is still accepted and is transparently upgraded to tiles.
|
||||||
|
type Layout struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
// Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and
|
||||||
|
// sized in these cells.
|
||||||
|
Grid string `yaml:"grid"`
|
||||||
|
// Tiles is the placement model. Preferred over Slots.
|
||||||
|
Tiles []Tile `yaml:"tiles,omitempty"`
|
||||||
|
// Slots is the legacy one-camera-per-cell model (row-major). Kept for
|
||||||
|
// backward compatibility; EffectiveTiles converts it to tiles.
|
||||||
|
Slots []string `yaml:"slots,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile places one camera at a rectangular region of the base grid.
|
||||||
|
type Tile struct {
|
||||||
|
Camera string `yaml:"camera"`
|
||||||
|
Col int `yaml:"col"`
|
||||||
|
Row int `yaml:"row"`
|
||||||
|
ColSpan int `yaml:"colspan,omitempty"` // defaults to 1
|
||||||
|
RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Span returns the tile's spans with zero values normalized to 1.
|
||||||
|
func (t Tile) Span() (colspan, rowspan int) {
|
||||||
|
colspan, rowspan = t.ColSpan, t.RowSpan
|
||||||
|
if colspan < 1 {
|
||||||
|
colspan = 1
|
||||||
|
}
|
||||||
|
if rowspan < 1 {
|
||||||
|
rowspan = 1
|
||||||
|
}
|
||||||
|
return colspan, rowspan
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveTiles returns the layout's tiles, normalizing spans and upgrading a
|
||||||
|
// legacy Slots list to 1x1 tiles when Tiles is empty.
|
||||||
|
func (l Layout) EffectiveTiles() []Tile {
|
||||||
|
if len(l.Tiles) > 0 {
|
||||||
|
out := make([]Tile, len(l.Tiles))
|
||||||
|
for i, t := range l.Tiles {
|
||||||
|
cs, rs := t.Span()
|
||||||
|
t.ColSpan, t.RowSpan = cs, rs
|
||||||
|
out[i] = t
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
cols, _, err := l.Dimensions()
|
||||||
|
if err != nil || cols == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []Tile
|
||||||
|
for i, cam := range l.Slots {
|
||||||
|
if cam == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Tile{Camera: cam, Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimensions parses Grid into cols, rows.
|
||||||
|
func (l Layout) Dimensions() (cols, rows int, err error) {
|
||||||
|
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(l.Grid)), "x", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("layout %q: grid %q must look like COLSxROWS", l.Name, l.Grid)
|
||||||
|
}
|
||||||
|
cols, err = strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
if err != nil || cols < 1 {
|
||||||
|
return 0, 0, fmt.Errorf("layout %q: bad column count in grid %q", l.Name, l.Grid)
|
||||||
|
}
|
||||||
|
rows, err = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil || rows < 1 {
|
||||||
|
return 0, 0, fmt.Errorf("layout %q: bad row count in grid %q", l.Name, l.Grid)
|
||||||
|
}
|
||||||
|
return cols, rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity is the number of cells in the grid.
|
||||||
|
func (l Layout) Capacity() int {
|
||||||
|
cols, rows, err := l.Dimensions()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return cols * rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// CameraByName returns the named camera, or nil if absent.
|
||||||
|
func (c *Config) CameraByName(name string) *Camera {
|
||||||
|
for i := range c.Cameras {
|
||||||
|
if c.Cameras[i].Name == name {
|
||||||
|
return &c.Cameras[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LayoutByName returns the named layout, or nil if absent.
|
||||||
|
func (c *Config) LayoutByName(name string) *Layout {
|
||||||
|
for i := range c.Layouts {
|
||||||
|
if c.Layouts[i].Name == name {
|
||||||
|
return &c.Layouts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active returns the currently selected layout, or nil.
|
||||||
|
func (c *Config) Active() *Layout {
|
||||||
|
if c.ActiveLayout == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.LayoutByName(c.ActiveLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults fills in sensible zero-value replacements. Called after load.
|
||||||
|
func (c *Config) Defaults() {
|
||||||
|
if c.Controller.RTSPPort == 0 {
|
||||||
|
c.Controller.RTSPPort = 7441
|
||||||
|
}
|
||||||
|
if c.Player.HWDec == "" {
|
||||||
|
c.Player.HWDec = "auto-safe"
|
||||||
|
}
|
||||||
|
if c.Player.RestartBackoffSeconds == 0 {
|
||||||
|
c.Player.RestartBackoffSeconds = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks referential integrity and returns the first problem found.
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, cam := range c.Cameras {
|
||||||
|
if cam.Name == "" {
|
||||||
|
return fmt.Errorf("a camera is missing a name")
|
||||||
|
}
|
||||||
|
if seen[cam.Name] {
|
||||||
|
return fmt.Errorf("duplicate camera name %q", cam.Name)
|
||||||
|
}
|
||||||
|
seen[cam.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutNames := map[string]bool{}
|
||||||
|
for _, l := range c.Layouts {
|
||||||
|
if l.Name == "" {
|
||||||
|
return fmt.Errorf("a layout is missing a name")
|
||||||
|
}
|
||||||
|
if layoutNames[l.Name] {
|
||||||
|
return fmt.Errorf("duplicate layout name %q", l.Name)
|
||||||
|
}
|
||||||
|
layoutNames[l.Name] = true
|
||||||
|
|
||||||
|
cols, rows, err := l.Dimensions()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(l.Tiles) > 0 {
|
||||||
|
if err := validateTiles(l, cols, rows, seen); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(l.Slots) > cols*rows {
|
||||||
|
return fmt.Errorf("layout %q: %d slots exceed grid capacity %d", l.Name, len(l.Slots), cols*rows)
|
||||||
|
}
|
||||||
|
for _, slot := range l.Slots {
|
||||||
|
if slot != "" && !seen[slot] {
|
||||||
|
return fmt.Errorf("layout %q references unknown camera %q", l.Name, slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.ActiveLayout != "" && !layoutNames[c.ActiveLayout] {
|
||||||
|
return fmt.Errorf("active_layout %q is not a defined layout", c.ActiveLayout)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTiles checks a tile-based layout: in-bounds, no overlap, known
|
||||||
|
// cameras, and within the MaxTiles cap.
|
||||||
|
func validateTiles(l Layout, cols, rows int, knownCameras map[string]bool) error {
|
||||||
|
if len(l.Tiles) > MaxTiles {
|
||||||
|
return fmt.Errorf("layout %q: %d tiles exceed the %d-camera limit", l.Name, len(l.Tiles), MaxTiles)
|
||||||
|
}
|
||||||
|
occupied := make([]bool, cols*rows)
|
||||||
|
for _, t := range l.Tiles {
|
||||||
|
cs, rs := t.Span()
|
||||||
|
if t.Col < 0 || t.Row < 0 || t.Col+cs > cols || t.Row+rs > rows {
|
||||||
|
return fmt.Errorf("layout %q: tile %q at (%d,%d)+%dx%d falls outside the %dx%d grid",
|
||||||
|
l.Name, t.Camera, t.Col, t.Row, cs, rs, cols, rows)
|
||||||
|
}
|
||||||
|
if t.Camera != "" && !knownCameras[t.Camera] {
|
||||||
|
return fmt.Errorf("layout %q references unknown camera %q", l.Name, t.Camera)
|
||||||
|
}
|
||||||
|
for r := t.Row; r < t.Row+rs; r++ {
|
||||||
|
for cc := t.Col; cc < t.Col+cs; cc++ {
|
||||||
|
idx := r*cols + cc
|
||||||
|
if occupied[idx] {
|
||||||
|
return fmt.Errorf("layout %q: tiles overlap at cell (col %d, row %d)", l.Name, cc, r)
|
||||||
|
}
|
||||||
|
occupied[idx] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPath returns the XDG config path, honoring $RTSP_STREAMER_CONFIG.
|
||||||
|
func DefaultPath() string {
|
||||||
|
if p := os.Getenv("RTSP_STREAMER_CONFIG"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
base := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if base == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
base = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "rtsp-streamer", "config.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads and validates the config at path. A missing file yields a
|
||||||
|
// zero-value config with defaults applied (not an error), so first-run tools
|
||||||
|
// can start from an empty state.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c := &Config{}
|
||||||
|
c.Defaults()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c Config
|
||||||
|
if err := yaml.Unmarshal(data, &c); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||||
|
}
|
||||||
|
c.Defaults()
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the config atomically (temp file + rename) so a crash mid-write
|
||||||
|
// never truncates the live config.
|
||||||
|
func Save(path string, c *Config) error {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("refusing to save invalid config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.yaml.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName) // no-op if rename succeeded
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpName, path)
|
||||||
|
}
|
||||||
41
internal/config/config_test.go
Normal file
41
internal/config/config_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLayoutDimensions(t *testing.T) {
|
||||||
|
good := map[string][2]int{"2x2": {2, 2}, "3X3": {3, 3}, " 4x2 ": {4, 2}, "1x1": {1, 1}}
|
||||||
|
for grid, want := range good {
|
||||||
|
l := Layout{Name: "t", Grid: grid}
|
||||||
|
c, r, err := l.Dimensions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grid %q: unexpected error %v", grid, err)
|
||||||
|
}
|
||||||
|
if c != want[0] || r != want[1] {
|
||||||
|
t.Errorf("grid %q: got %dx%d want %dx%d", grid, c, r, want[0], want[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "2", "2x", "x2", "0x2", "axb"} {
|
||||||
|
if _, _, err := (Layout{Name: "t", Grid: bad}).Dimensions(); err == nil {
|
||||||
|
t.Errorf("grid %q: expected error", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsUnknownCameraRef(t *testing.T) {
|
||||||
|
c := &Config{
|
||||||
|
Cameras: []Camera{{Name: "front"}},
|
||||||
|
Layouts: []Layout{{Name: "l", Grid: "2x2", Slots: []string{"front", "missing", "", ""}}},
|
||||||
|
}
|
||||||
|
c.Defaults()
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Fatal("expected validation error for unknown camera reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsTooManySlots(t *testing.T) {
|
||||||
|
c := &Config{Layouts: []Layout{{Name: "l", Grid: "1x1", Slots: []string{"", ""}}}}
|
||||||
|
c.Defaults()
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Fatal("expected validation error for slot overflow")
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/config/tiles_test.go
Normal file
58
internal/config/tiles_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEffectiveTilesFromSlots(t *testing.T) {
|
||||||
|
l := Layout{Name: "l", Grid: "2x2", Slots: []string{"a", "", "", "b"}}
|
||||||
|
tiles := l.EffectiveTiles()
|
||||||
|
if len(tiles) != 2 {
|
||||||
|
t.Fatalf("got %d tiles, want 2", len(tiles))
|
||||||
|
}
|
||||||
|
// "a" at cell 0 -> (0,0); "b" at cell 3 -> (1,1).
|
||||||
|
if tiles[0].Camera != "a" || tiles[0].Col != 0 || tiles[0].Row != 0 {
|
||||||
|
t.Errorf("tile0 = %+v", tiles[0])
|
||||||
|
}
|
||||||
|
if tiles[1].Camera != "b" || tiles[1].Col != 1 || tiles[1].Row != 1 {
|
||||||
|
t.Errorf("tile1 = %+v", tiles[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTiles(t *testing.T) {
|
||||||
|
base := func(tiles []Tile) *Config {
|
||||||
|
c := &Config{
|
||||||
|
Cameras: []Camera{{Name: "a"}, {Name: "b"}},
|
||||||
|
Layouts: []Layout{{Name: "l", Grid: "4x4", Tiles: tiles}},
|
||||||
|
}
|
||||||
|
c.Defaults()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid: a 3x4 main plus a 1x1 side.
|
||||||
|
if err := base([]Tile{{Camera: "a", Col: 0, Row: 0, ColSpan: 3, RowSpan: 4}, {Camera: "b", Col: 3, Row: 0}}).Validate(); err != nil {
|
||||||
|
t.Errorf("valid layout rejected: %v", err)
|
||||||
|
}
|
||||||
|
// Overlap.
|
||||||
|
if err := base([]Tile{{Camera: "a", Col: 0, Row: 0, ColSpan: 2, RowSpan: 2}, {Camera: "b", Col: 1, Row: 1}}).Validate(); err == nil {
|
||||||
|
t.Error("expected overlap error")
|
||||||
|
}
|
||||||
|
// Out of bounds.
|
||||||
|
if err := base([]Tile{{Camera: "a", Col: 3, Row: 0, ColSpan: 2, RowSpan: 1}}).Validate(); err == nil {
|
||||||
|
t.Error("expected out-of-bounds error")
|
||||||
|
}
|
||||||
|
// Unknown camera.
|
||||||
|
if err := base([]Tile{{Camera: "ghost", Col: 0, Row: 0}}).Validate(); err == nil {
|
||||||
|
t.Error("expected unknown-camera error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTilesCap(t *testing.T) {
|
||||||
|
var tiles []Tile
|
||||||
|
for i := 0; i < MaxTiles+1; i++ {
|
||||||
|
tiles = append(tiles, Tile{Camera: "a", Col: i, Row: 0})
|
||||||
|
}
|
||||||
|
c := &Config{Cameras: []Camera{{Name: "a"}}, Layouts: []Layout{{Name: "l", Grid: "20x1", Tiles: tiles}}}
|
||||||
|
c.Defaults()
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Errorf("expected error exceeding %d-tile cap", MaxTiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
316
internal/daemon/daemon.go
Normal file
316
internal/daemon/daemon.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// Package daemon is the orchestrator: it reads the config, asks the compositor
|
||||||
|
// for the output geometry, launches one supervised mpv per occupied grid slot,
|
||||||
|
// tiles them, and exposes a control socket so the layout can be switched live.
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/compositor"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/player"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Daemon owns the running video wall.
|
||||||
|
type Daemon struct {
|
||||||
|
cfgPath string
|
||||||
|
log *slog.Logger
|
||||||
|
comp *compositor.Client
|
||||||
|
runDir string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg *config.Config
|
||||||
|
players []*player.Player
|
||||||
|
layout string
|
||||||
|
cancelLo context.CancelFunc // cancels the current layout's supervisors
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a daemon bound to a config path.
|
||||||
|
func New(cfgPath string, log *slog.Logger) (*Daemon, error) {
|
||||||
|
comp, err := compositor.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runDir := ipc.RunDir()
|
||||||
|
if err := os.MkdirAll(runDir, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Daemon{cfgPath: cfgPath, log: log, comp: comp, runDir: runDir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the wall and blocks until ctx is cancelled.
|
||||||
|
func (d *Daemon) Run(ctx context.Context) error {
|
||||||
|
if err := d.comp.PrepareForMPV(ctx); err != nil {
|
||||||
|
d.log.Warn("could not preinstall mpv window rules", "err", err)
|
||||||
|
}
|
||||||
|
if err := d.reload(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go d.serveControl(ctx)
|
||||||
|
go d.healthLoop(ctx)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
d.log.Info("shutting down")
|
||||||
|
d.stopLayout()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolution returns the render size, from config or the live output.
|
||||||
|
func (d *Daemon) resolution(ctx context.Context, cfg *config.Config) (int, int) {
|
||||||
|
if cfg.Display.Width > 0 && cfg.Display.Height > 0 {
|
||||||
|
return cfg.Display.Width, cfg.Display.Height
|
||||||
|
}
|
||||||
|
if out, err := d.comp.PrimaryOutput(ctx); err == nil && out.CurrentMode.Width > 0 {
|
||||||
|
return out.CurrentMode.Width, out.CurrentMode.Height
|
||||||
|
}
|
||||||
|
d.log.Warn("falling back to 1920x1080; set display.width/height to override")
|
||||||
|
return 1920, 1080
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload re-reads config from disk and applies the active layout.
|
||||||
|
func (d *Daemon) reload(ctx context.Context) error {
|
||||||
|
cfg, err := config.Load(d.cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
d.cfg = cfg
|
||||||
|
d.mu.Unlock()
|
||||||
|
name := cfg.ActiveLayout
|
||||||
|
if name == "" {
|
||||||
|
d.log.Warn("no active_layout set; nothing to display")
|
||||||
|
d.stopLayout()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.applyLayout(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLayout tears down the current wall and builds the named layout.
|
||||||
|
func (d *Daemon) applyLayout(ctx context.Context, name string) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
cfg := d.cfg
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
layout := cfg.LayoutByName(name)
|
||||||
|
if layout == nil {
|
||||||
|
return fmt.Errorf("layout %q not found", name)
|
||||||
|
}
|
||||||
|
cols, rows, err := layout.Dimensions()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, h := d.resolution(ctx, cfg)
|
||||||
|
tiles := layout.EffectiveTiles()
|
||||||
|
|
||||||
|
d.stopLayout()
|
||||||
|
|
||||||
|
loCtx, cancel := context.WithCancel(ctx)
|
||||||
|
var players []*player.Player
|
||||||
|
|
||||||
|
for slot, tile := range tiles {
|
||||||
|
if tile.Camera == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cam := cfg.CameraByName(tile.Camera)
|
||||||
|
if cam == nil || cam.Disabled || cam.RTSP == "" {
|
||||||
|
d.log.Warn("skipping tile: camera unavailable", "slot", slot, "camera", tile.Camera)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cs, rs := tile.Span()
|
||||||
|
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
|
||||||
|
p := player.New(slot, cam.Name, cam.RTSP, cfg.Player, d.runDir, d.log)
|
||||||
|
players = append(players, p)
|
||||||
|
|
||||||
|
d.wg.Add(1)
|
||||||
|
go func() { defer d.wg.Done(); p.Supervise(loCtx) }()
|
||||||
|
|
||||||
|
// Position the window once it maps. Done per-tile so a slow camera
|
||||||
|
// doesn't block the others.
|
||||||
|
d.wg.Add(1)
|
||||||
|
go func(p *player.Player, rect compositor.Rect) {
|
||||||
|
defer d.wg.Done()
|
||||||
|
d.placeWhenReady(loCtx, p, rect)
|
||||||
|
}(p, rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
d.players = players
|
||||||
|
d.layout = name
|
||||||
|
d.cancelLo = cancel
|
||||||
|
d.cfg.ActiveLayout = name
|
||||||
|
d.mu.Unlock()
|
||||||
|
d.log.Info("layout applied", "layout", name, "tiles", len(players), "grid", layout.Grid, "res", fmt.Sprintf("%dx%d", w, h))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeWhenReady waits for the mpv window to map then tiles it, retrying while
|
||||||
|
// the layout is active (Supervise may relaunch mpv with a new pid).
|
||||||
|
func (d *Daemon) placeWhenReady(ctx context.Context, p *player.Player, rect compositor.Rect) {
|
||||||
|
var lastPID int
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pid := p.PID()
|
||||||
|
if pid != 0 && pid != lastPID {
|
||||||
|
if err := d.comp.WaitForWindow(ctx, pid, 15*time.Second); err == nil {
|
||||||
|
if err := d.comp.Place(ctx, pid, rect); err != nil {
|
||||||
|
d.log.Warn("place failed", "slot", p.Slot, "err", err)
|
||||||
|
} else {
|
||||||
|
lastPID = pid
|
||||||
|
d.log.Debug("window placed", "slot", p.Slot, "pid", pid, "rect", rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopLayout cancels supervisors and kills current mpv processes.
|
||||||
|
func (d *Daemon) stopLayout() {
|
||||||
|
d.mu.Lock()
|
||||||
|
cancel := d.cancelLo
|
||||||
|
players := d.players
|
||||||
|
d.cancelLo = nil
|
||||||
|
d.players = nil
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
for _, p := range players {
|
||||||
|
p.Stop()
|
||||||
|
}
|
||||||
|
d.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthLoop periodically nudges stalled streams. Supervise already restarts
|
||||||
|
// exited mpv; this catches the "process alive but frozen" case.
|
||||||
|
func (d *Daemon) healthLoop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(20 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
stalls := map[int]int{}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
players := append([]*player.Player(nil), d.players...)
|
||||||
|
d.mu.Unlock()
|
||||||
|
for _, p := range players {
|
||||||
|
if !p.Running() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Healthy() {
|
||||||
|
stalls[p.Slot] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stalls[p.Slot]++
|
||||||
|
if stalls[p.Slot] >= 2 {
|
||||||
|
d.log.Warn("stream stalled, forcing restart", "slot", p.Slot, "camera", p.Name)
|
||||||
|
p.Stop() // Supervise relaunches
|
||||||
|
stalls[p.Slot] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveControl accepts control-socket connections for status/reload/set-layout.
|
||||||
|
func (d *Daemon) serveControl(ctx context.Context) {
|
||||||
|
path := ipc.SocketPath()
|
||||||
|
_ = os.Remove(path)
|
||||||
|
ln, err := net.Listen("unix", path)
|
||||||
|
if err != nil {
|
||||||
|
d.log.Error("control socket listen failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() { <-ctx.Done(); ln.Close(); os.Remove(path) }()
|
||||||
|
d.log.Info("control socket ready", "path", path)
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go d.handleControl(ctx, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) handleControl(ctx context.Context, conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
var req ipc.Request
|
||||||
|
if err := json.NewDecoder(conn).Decode(&req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := d.dispatch(ctx, req)
|
||||||
|
_ = json.NewEncoder(conn).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) dispatch(ctx context.Context, req ipc.Request) ipc.Response {
|
||||||
|
switch req.Cmd {
|
||||||
|
case "status":
|
||||||
|
return d.status()
|
||||||
|
case "reload":
|
||||||
|
if err := d.reload(ctx); err != nil {
|
||||||
|
return ipc.Response{OK: false, Error: err.Error()}
|
||||||
|
}
|
||||||
|
return d.status()
|
||||||
|
case "set-layout":
|
||||||
|
if err := d.applyLayout(ctx, req.Name); err != nil {
|
||||||
|
return ipc.Response{OK: false, Error: err.Error()}
|
||||||
|
}
|
||||||
|
// Persist the choice so a restart keeps it.
|
||||||
|
if err := d.persistActiveLayout(req.Name); err != nil {
|
||||||
|
d.log.Warn("could not persist active layout", "err", err)
|
||||||
|
}
|
||||||
|
return d.status()
|
||||||
|
default:
|
||||||
|
return ipc.Response{OK: false, Error: fmt.Sprintf("unknown command %q", req.Cmd)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) persistActiveLayout(name string) error {
|
||||||
|
cfg, err := config.Load(d.cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.ActiveLayout = name
|
||||||
|
return config.Save(d.cfgPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) status() ipc.Response {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
resp := ipc.Response{OK: true, ActiveLayout: d.layout}
|
||||||
|
for _, p := range d.players {
|
||||||
|
resp.Slots = append(resp.Slots, ipc.SlotStatus{
|
||||||
|
Slot: p.Slot,
|
||||||
|
Camera: p.Name,
|
||||||
|
PID: p.PID(),
|
||||||
|
Running: p.Running(),
|
||||||
|
Healthy: p.Healthy(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
75
internal/ipc/ipc.go
Normal file
75
internal/ipc/ipc.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Package ipc defines the tiny control protocol shared by the daemon (server)
|
||||||
|
// and the CLI/TUI (clients). Messages are newline-delimited JSON over a unix
|
||||||
|
// socket, so switching layouts or reading status never requires restarting
|
||||||
|
// the video wall.
|
||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is a command sent to the daemon.
|
||||||
|
type Request struct {
|
||||||
|
Cmd string `json:"cmd"` // "status" | "reload" | "set-layout"
|
||||||
|
Name string `json:"name,omitempty"` // layout name for set-layout
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlotStatus reports one grid cell's state.
|
||||||
|
type SlotStatus struct {
|
||||||
|
Slot int `json:"slot"`
|
||||||
|
Camera string `json:"camera"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is the daemon's reply.
|
||||||
|
type Response struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ActiveLayout string `json:"active_layout,omitempty"`
|
||||||
|
Slots []SlotStatus `json:"slots,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocketPath returns the control socket path inside the runtime dir.
|
||||||
|
func SocketPath() string {
|
||||||
|
return filepath.Join(RunDir(), "control.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDir is the per-user runtime directory for sockets, created on demand.
|
||||||
|
func RunDir() string {
|
||||||
|
base := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
if base == "" {
|
||||||
|
base = filepath.Join(os.TempDir(), "rtsp-streamer-"+strconv.Itoa(os.Getuid()))
|
||||||
|
} else {
|
||||||
|
base = filepath.Join(base, "rtsp-streamer")
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send dials the daemon, sends one request, and returns the reply.
|
||||||
|
func Send(req Request) (*Response, error) {
|
||||||
|
conn, err := net.DialTimeout("unix", SocketPath(), 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot reach daemon (is it running?): %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
enc := json.NewEncoder(conn)
|
||||||
|
if err := enc.Encode(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp Response
|
||||||
|
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
233
internal/player/player.go
Normal file
233
internal/player/player.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// Package player manages one mpv process per camera stream. Each stream runs
|
||||||
|
// independently so a single dead camera never disturbs the rest of the wall;
|
||||||
|
// the manager relaunches only the slot that failed, with backoff.
|
||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Player supervises a single mpv instance bound to one grid slot.
|
||||||
|
type Player struct {
|
||||||
|
Slot int // grid cell index (row-major)
|
||||||
|
Name string // camera name, for logs/titles
|
||||||
|
URL string // RTSP(S) source
|
||||||
|
|
||||||
|
cfg config.Player
|
||||||
|
ipcPath string
|
||||||
|
runDir string
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cmd *exec.Cmd
|
||||||
|
started time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a player. runDir is where the mpv IPC socket lives.
|
||||||
|
func New(slot int, name, url string, cfg config.Player, runDir string, log *slog.Logger) *Player {
|
||||||
|
return &Player{
|
||||||
|
Slot: slot,
|
||||||
|
Name: name,
|
||||||
|
URL: url,
|
||||||
|
cfg: cfg,
|
||||||
|
runDir: runDir,
|
||||||
|
ipcPath: filepath.Join(runDir, fmt.Sprintf("mpv-slot-%d.sock", slot)),
|
||||||
|
log: log.With("slot", slot, "camera", name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title is the window title mpv advertises; the compositor could match on it,
|
||||||
|
// though we prefer matching by PID.
|
||||||
|
func (p *Player) Title() string {
|
||||||
|
return fmt.Sprintf("rtsp-streamer:slot-%d", p.Slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) args() []string {
|
||||||
|
args := []string{
|
||||||
|
"--no-config",
|
||||||
|
"--force-window=yes",
|
||||||
|
"--idle=no",
|
||||||
|
"--keep-open=no",
|
||||||
|
"--no-osc",
|
||||||
|
"--no-input-default-bindings",
|
||||||
|
"--input-cursor=no",
|
||||||
|
"--cursor-autohide=always",
|
||||||
|
"--no-border",
|
||||||
|
"--fullscreen=no", // we tile via the compositor, not fullscreen
|
||||||
|
"--title=" + p.Title(),
|
||||||
|
"--input-ipc-server=" + p.ipcPath,
|
||||||
|
"--hwdec=" + p.cfg.HWDec,
|
||||||
|
// Live-stream hygiene: prefer TCP transport, keep buffers small.
|
||||||
|
"--rtsp-transport=tcp",
|
||||||
|
"--profile=low-latency",
|
||||||
|
"--cache=no",
|
||||||
|
"--demuxer-lavf-o=stimeout=5000000",
|
||||||
|
}
|
||||||
|
if p.cfg.Profile != "" && p.cfg.Profile != "low-latency" {
|
||||||
|
args = append(args, "--profile="+p.cfg.Profile)
|
||||||
|
}
|
||||||
|
args = append(args, p.cfg.ExtraArgs...)
|
||||||
|
args = append(args, p.URL)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// PID returns the running mpv process id, or 0 if not running.
|
||||||
|
func (p *Player) PID() int {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.cmd == nil || p.cmd.Process == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return p.cmd.Process.Pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running reports whether the process is currently alive.
|
||||||
|
func (p *Player) Running() bool {
|
||||||
|
return p.PID() != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// start launches mpv once. Caller owns retry/backoff.
|
||||||
|
func (p *Player) start(ctx context.Context) error {
|
||||||
|
if err := os.Remove(p.ipcPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
p.log.Warn("stale ipc socket", "err", err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "mpv", p.args()...)
|
||||||
|
// Inherit the caller's environment (WAYLAND_DISPLAY etc. must be set).
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
// Discard mpv's chatty stdout/stderr; errors surface via exit code.
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("starting mpv: %w", err)
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.cmd = cmd
|
||||||
|
p.started = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.log.Info("mpv started", "pid", cmd.Process.Pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervise runs mpv and relaunches it whenever it exits, until ctx is
|
||||||
|
// cancelled. Backoff prevents a hot loop when a camera is unreachable.
|
||||||
|
func (p *Player) Supervise(ctx context.Context) {
|
||||||
|
backoff := time.Duration(p.cfg.RestartBackoffSeconds) * time.Second
|
||||||
|
if backoff <= 0 {
|
||||||
|
backoff = 3 * time.Second
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.start(ctx); err != nil {
|
||||||
|
p.log.Error("failed to start mpv", "err", err)
|
||||||
|
if !sleep(ctx, backoff) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
cmd := p.cmd
|
||||||
|
p.mu.Unlock()
|
||||||
|
err := cmd.Wait()
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.cmd = nil
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.log.Warn("mpv exited, will restart", "err", err, "after", backoff)
|
||||||
|
if !sleep(ctx, backoff) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the mpv process (SIGTERM, then SIGKILL after a grace period).
|
||||||
|
func (p *Player) Stop() {
|
||||||
|
p.mu.Lock()
|
||||||
|
cmd := p.cmd
|
||||||
|
p.mu.Unlock()
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { _, _ = cmd.Process.Wait(); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command sends a JSON IPC command to mpv and returns the decoded reply. Used
|
||||||
|
// for health probes and live property changes.
|
||||||
|
func (p *Player) Command(args ...any) (map[string]any, error) {
|
||||||
|
conn, err := net.DialTimeout("unix", p.ipcPath, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string]any{"command": args})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(append(payload, '\n')); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(conn)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var reply map[string]any
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &reply); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// mpv emits async events too; the command reply carries "error".
|
||||||
|
if _, ok := reply["error"]; ok {
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no reply from mpv ipc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthy probes mpv over IPC and reports whether it is actively playing (has
|
||||||
|
// a finite time position advancing). A false result signals the daemon to
|
||||||
|
// consider restarting the slot even if the process is still alive (frozen).
|
||||||
|
func (p *Player) Healthy() bool {
|
||||||
|
if !p.Running() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
reply, err := p.Command("get_property", "time-pos")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return reply["error"] == "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleep(ctx context.Context, d time.Duration) bool {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-t.C:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
358
internal/protect/protect.go
Normal file
358
internal/protect/protect.go
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
// Package protect is a minimal client for the UniFi Protect local API.
|
||||||
|
//
|
||||||
|
// UniFi Protect has no officially documented public API, but the local
|
||||||
|
// endpoints used here (/api/auth/login and /proxy/protect/api/bootstrap) are
|
||||||
|
// stable and are the same ones the Home Assistant integration and the
|
||||||
|
// uiprotect/pyunifiprotect libraries rely on. We authenticate as a local
|
||||||
|
// Protect user, read the bootstrap document, and construct RTSPS URLs from
|
||||||
|
// each camera's enabled channel alias.
|
||||||
|
package protect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client talks to a single UniFi Protect controller.
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
rtspPort int
|
||||||
|
http *http.Client
|
||||||
|
csrf string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera is a discovered Protect camera with its resolved stream URLs.
|
||||||
|
type Camera struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
State string // "CONNECTED", "DISCONNECTED", ...
|
||||||
|
Channels []Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel is one encoding profile (high/medium/low) on a camera.
|
||||||
|
type Channel struct {
|
||||||
|
ID int
|
||||||
|
Name string // "High", "Medium", "Low"
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
RTSPEnabled bool
|
||||||
|
RTSPAlias string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client. verifyTLS=false accepts the console's self-signed cert.
|
||||||
|
func New(host string, rtspPort int, verifyTLS bool) (*Client, error) {
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rtspPort == 0 {
|
||||||
|
rtspPort = 7441
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
rtspPort: rtspPort,
|
||||||
|
http: &http.Client{
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
Jar: jar,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS}, //nolint:gosec // self-signed console cert
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates and captures the session cookie + CSRF token. UniFi OS
|
||||||
|
// returns the CSRF token in a response header on successful login.
|
||||||
|
func (c *Client) Login(ctx context.Context, username, password string) error {
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"rememberMe": true,
|
||||||
|
})
|
||||||
|
url := fmt.Sprintf("https://%s/api/auth/login", c.host)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connecting to controller %s: %w", c.host, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return fmt.Errorf("login failed (%s): %s", resp.Status, strings.TrimSpace(string(snippet)))
|
||||||
|
}
|
||||||
|
// UniFi OS exposes the CSRF token via header; capture whichever casing.
|
||||||
|
if tok := resp.Header.Get("X-CSRF-Token"); tok != "" {
|
||||||
|
c.csrf = tok
|
||||||
|
} else if tok := resp.Header.Get("X-Updated-CSRF-Token"); tok != "" {
|
||||||
|
c.csrf = tok
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrap is the subset of the Protect bootstrap document we care about.
|
||||||
|
type bootstrap struct {
|
||||||
|
Cameras []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
IsRTSPEnabled bool `json:"isRtspEnabled"`
|
||||||
|
ChannelsWrapper []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
IsRTSPEnabled bool `json:"isRtspEnabled"`
|
||||||
|
RTSPAlias string `json:"rtspAlias"`
|
||||||
|
} `json:"channels"`
|
||||||
|
} `json:"cameras"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cameras fetches the bootstrap document and returns the camera list.
|
||||||
|
func (c *Client) Cameras(ctx context.Context) ([]Camera, error) {
|
||||||
|
url := fmt.Sprintf("https://%s/proxy/protect/api/bootstrap", c.host)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.csrf != "" {
|
||||||
|
req.Header.Set("X-CSRF-Token", c.csrf)
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("bootstrap failed (%s): %s", resp.Status, strings.TrimSpace(string(snippet)))
|
||||||
|
}
|
||||||
|
var b bootstrap
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&b); err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding bootstrap: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Camera, 0, len(b.Cameras))
|
||||||
|
for _, bc := range b.Cameras {
|
||||||
|
cam := Camera{ID: bc.ID, Name: bc.Name, State: bc.State}
|
||||||
|
for _, ch := range bc.ChannelsWrapper {
|
||||||
|
cam.Channels = append(cam.Channels, Channel{
|
||||||
|
ID: ch.ID,
|
||||||
|
Name: ch.Name,
|
||||||
|
Width: ch.Width,
|
||||||
|
Height: ch.Height,
|
||||||
|
RTSPEnabled: ch.IsRTSPEnabled,
|
||||||
|
RTSPAlias: ch.RTSPAlias,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, cam)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamURL builds the RTSPS URL for a channel alias on this controller.
|
||||||
|
// enableSrtp is required by Protect's RTSPS endpoint.
|
||||||
|
func (c *Client) StreamURL(alias string) string {
|
||||||
|
return fmt.Sprintf("rtsps://%s:%d/%s?enableSrtp", c.host, c.rtspPort, alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BestEnabledChannel returns the highest-resolution channel that has RTSP
|
||||||
|
// enabled, or nil if none are enabled. Preferring the top channel gives the
|
||||||
|
// sharpest wall tile; callers can pick a lower one for dense grids.
|
||||||
|
func (cam Camera) BestEnabledChannel() *Channel {
|
||||||
|
var best *Channel
|
||||||
|
for i := range cam.Channels {
|
||||||
|
ch := &cam.Channels[i]
|
||||||
|
if !ch.RTSPEnabled || ch.RTSPAlias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if best == nil || ch.Width*ch.Height > best.Width*best.Height {
|
||||||
|
best = ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// LowestEnabledChannel returns the lowest-resolution enabled channel, useful
|
||||||
|
// for dense grids where a substream is plenty.
|
||||||
|
func (cam Camera) LowestEnabledChannel() *Channel {
|
||||||
|
var low *Channel
|
||||||
|
for i := range cam.Channels {
|
||||||
|
ch := &cam.Channels[i]
|
||||||
|
if !ch.RTSPEnabled || ch.RTSPAlias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if low == nil || ch.Width*ch.Height < low.Width*low.Height {
|
||||||
|
low = ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelByPreference picks a channel to enable RTSP on, by preference
|
||||||
|
// "high" | "medium" | "low". It matches on the channel name first (Protect
|
||||||
|
// labels them "High"/"Medium"/"Low") and falls back to resolution ranking so
|
||||||
|
// it still works on cameras with unusual channel names.
|
||||||
|
func (cam Camera) ChannelByPreference(pref string) *Channel {
|
||||||
|
pref = strings.ToLower(strings.TrimSpace(pref))
|
||||||
|
for i := range cam.Channels {
|
||||||
|
if strings.ToLower(cam.Channels[i].Name) == pref {
|
||||||
|
return &cam.Channels[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cam.Channels) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pick *Channel
|
||||||
|
for i := range cam.Channels {
|
||||||
|
ch := &cam.Channels[i]
|
||||||
|
if pick == nil {
|
||||||
|
pick = ch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch pref {
|
||||||
|
case "low":
|
||||||
|
if ch.Width*ch.Height < pick.Width*pick.Height {
|
||||||
|
pick = ch
|
||||||
|
}
|
||||||
|
default: // treat anything else as "highest resolution"
|
||||||
|
if ch.Width*ch.Height > pick.Width*pick.Height {
|
||||||
|
pick = ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pick
|
||||||
|
}
|
||||||
|
|
||||||
|
// do issues an authenticated request to a Protect API path (e.g.
|
||||||
|
// "/proxy/protect/api/cameras/<id>"), attaching the session cookie (via the
|
||||||
|
// client's jar) and the CSRF token. The caller closes the response body.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
|
||||||
|
var rdr io.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rdr = bytes.NewReader(raw)
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("https://%s%s", c.host, path)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if c.csrf != "" {
|
||||||
|
req.Header.Set("X-CSRF-Token", c.csrf)
|
||||||
|
}
|
||||||
|
return c.http.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableRTSP turns on RTSP for one channel of a camera and returns the newly
|
||||||
|
// assigned rtspAlias. It reads the camera's current channels as raw JSON and
|
||||||
|
// flips only isRtspEnabled on the target channel before PATCHing them back, so
|
||||||
|
// no other encoder settings (bitrate, fps, ...) are disturbed.
|
||||||
|
func (c *Client) EnableRTSP(ctx context.Context, cameraID string, channelID int) (string, error) {
|
||||||
|
getResp, err := c.do(ctx, http.MethodGet, "/proxy/protect/api/cameras/"+cameraID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
if getResp.StatusCode != http.StatusOK {
|
||||||
|
snippet, _ := io.ReadAll(io.LimitReader(getResp.Body, 512))
|
||||||
|
return "", fmt.Errorf("fetching camera %s (%s): %s", cameraID, getResp.Status, strings.TrimSpace(string(snippet)))
|
||||||
|
}
|
||||||
|
// Keep channels as raw maps to preserve every field we don't touch.
|
||||||
|
var cam struct {
|
||||||
|
Channels []map[string]any `json:"channels"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(getResp.Body).Decode(&cam); err != nil {
|
||||||
|
return "", fmt.Errorf("decoding camera %s: %w", cameraID, err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, ch := range cam.Channels {
|
||||||
|
id, ok := ch["id"].(float64)
|
||||||
|
if ok && int(id) == channelID {
|
||||||
|
ch["isRtspEnabled"] = true
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return "", fmt.Errorf("camera %s has no channel %d", cameraID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
patchResp, err := c.do(ctx, http.MethodPatch, "/proxy/protect/api/cameras/"+cameraID,
|
||||||
|
map[string]any{"channels": cam.Channels})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer patchResp.Body.Close()
|
||||||
|
if patchResp.StatusCode != http.StatusOK {
|
||||||
|
snippet, _ := io.ReadAll(io.LimitReader(patchResp.Body, 512))
|
||||||
|
return "", fmt.Errorf("enabling RTSP on camera %s (%s): %s", cameraID, patchResp.Status, strings.TrimSpace(string(snippet)))
|
||||||
|
}
|
||||||
|
var updated struct {
|
||||||
|
Channels []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
RTSPAlias string `json:"rtspAlias"`
|
||||||
|
} `json:"channels"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(patchResp.Body).Decode(&updated); err != nil {
|
||||||
|
return "", fmt.Errorf("decoding RTSP-enable response: %w", err)
|
||||||
|
}
|
||||||
|
for _, ch := range updated.Channels {
|
||||||
|
if ch.ID == channelID {
|
||||||
|
if ch.RTSPAlias == "" {
|
||||||
|
return "", fmt.Errorf("camera %s channel %d still has no rtspAlias after enabling", cameraID, channelID)
|
||||||
|
}
|
||||||
|
return ch.RTSPAlias, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("channel %d missing from RTSP-enable response", channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableMissing enables RTSP on the preferred channel for every camera that
|
||||||
|
// currently has no RTSP-enabled channel, mutating cams in place so their
|
||||||
|
// BestEnabledChannel/LowestEnabledChannel become usable. It returns the names
|
||||||
|
// it enabled and, per camera name, any error encountered.
|
||||||
|
func (c *Client) EnableMissing(ctx context.Context, cams []Camera, pref string) (enabled []string, failed map[string]error) {
|
||||||
|
failed = map[string]error{}
|
||||||
|
for i := range cams {
|
||||||
|
cam := &cams[i]
|
||||||
|
if cam.BestEnabledChannel() != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := cam.ChannelByPreference(pref)
|
||||||
|
if target == nil {
|
||||||
|
failed[cam.Name] = fmt.Errorf("no channels to enable")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alias, err := c.EnableRTSP(ctx, cam.ID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
failed[cam.Name] = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Reflect the change in the in-memory model.
|
||||||
|
for j := range cam.Channels {
|
||||||
|
if cam.Channels[j].ID == target.ID {
|
||||||
|
cam.Channels[j].RTSPEnabled = true
|
||||||
|
cam.Channels[j].RTSPAlias = alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabled = append(enabled, cam.Name)
|
||||||
|
}
|
||||||
|
return enabled, failed
|
||||||
|
}
|
||||||
197
internal/tui/gridedit.go
Normal file
197
internal/tui/gridedit.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity;
|
||||||
|
// the number of *tiles* (cameras) is separately capped at config.MaxTiles.
|
||||||
|
const maxGridDim = 8
|
||||||
|
|
||||||
|
// migrateToTiles converts a layout to the tile model in place so the grid
|
||||||
|
// editor always works on tiles (legacy slot layouts are upgraded on open).
|
||||||
|
func (m *model) migrateToTiles(idx int) {
|
||||||
|
l := &m.cfg.Layouts[idx]
|
||||||
|
l.Tiles = l.EffectiveTiles()
|
||||||
|
l.Slots = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) curLayout() *config.Layout { return &m.cfg.Layouts[m.editLayout] }
|
||||||
|
|
||||||
|
// usedCameras is the set of camera names already placed in the current layout,
|
||||||
|
// so the picker can flag ones you'd be adding twice.
|
||||||
|
func (m *model) usedCameras() map[string]bool {
|
||||||
|
used := map[string]bool{}
|
||||||
|
for _, t := range m.curLayout().Tiles {
|
||||||
|
if t.Camera != "" {
|
||||||
|
used[t.Camera] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return used
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) gridDims() (cols, rows int) {
|
||||||
|
cols, rows, err := m.curLayout().Dimensions()
|
||||||
|
if err != nil || cols < 1 || rows < 1 {
|
||||||
|
return 1, 1
|
||||||
|
}
|
||||||
|
return cols, rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// tileIndexAt returns the index of the tile covering (col,row), or -1.
|
||||||
|
func (m *model) tileIndexAt(col, row int) int {
|
||||||
|
for i, t := range m.curLayout().Tiles {
|
||||||
|
cs, rs := t.Span()
|
||||||
|
if col >= t.Col && col < t.Col+cs && row >= t.Row && row < t.Row+rs {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionFree reports whether the rectangle fits the grid and overlaps no tile
|
||||||
|
// other than excludeIdx.
|
||||||
|
func (m *model) regionFree(excludeIdx, col, row, colspan, rowspan int) bool {
|
||||||
|
cols, rows := m.gridDims()
|
||||||
|
if col < 0 || row < 0 || col+colspan > cols || row+rowspan > rows {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, t := range m.curLayout().Tiles {
|
||||||
|
if i == excludeIdx {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cs, rs := t.Span()
|
||||||
|
if col < t.Col+cs && col+colspan > t.Col && row < t.Row+rs && row+rowspan > t.Row {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
cols, rows := m.gridDims()
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "backspace":
|
||||||
|
m.screen, m.cursor = screenLayouts, m.editLayout
|
||||||
|
case "up", "k":
|
||||||
|
if m.edRow > 0 {
|
||||||
|
m.edRow--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.edRow < rows-1 {
|
||||||
|
m.edRow++
|
||||||
|
}
|
||||||
|
case "left", "h":
|
||||||
|
if m.edCol > 0 {
|
||||||
|
m.edCol--
|
||||||
|
}
|
||||||
|
case "right", "l":
|
||||||
|
if m.edCol < cols-1 {
|
||||||
|
m.edCol++
|
||||||
|
}
|
||||||
|
case "enter", "a", " ":
|
||||||
|
m.screen, m.cursor = screenCameraPicker, 0
|
||||||
|
case "c":
|
||||||
|
m.clearAt()
|
||||||
|
case "L": // grow wider (toward the right)
|
||||||
|
m.resizeTile(1, 0)
|
||||||
|
case "H": // shrink narrower (from the right)
|
||||||
|
m.resizeTile(-1, 0)
|
||||||
|
case "J": // grow taller (toward the bottom)
|
||||||
|
m.resizeTile(0, 1)
|
||||||
|
case "K": // shrink shorter (from the bottom)
|
||||||
|
m.resizeTile(0, -1)
|
||||||
|
case "]":
|
||||||
|
m.resizeGrid(1, 0)
|
||||||
|
case "[":
|
||||||
|
m.resizeGrid(-1, 0)
|
||||||
|
case "}":
|
||||||
|
m.resizeGrid(0, 1)
|
||||||
|
case "{":
|
||||||
|
m.resizeGrid(0, -1)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignCamera places the picker's choice at the cursor cell: updates the tile
|
||||||
|
// there, creates a 1x1 tile on an empty cell, or clears when choice is empty.
|
||||||
|
func (m *model) assignCamera(name string) {
|
||||||
|
l := m.curLayout()
|
||||||
|
idx := m.tileIndexAt(m.edCol, m.edRow)
|
||||||
|
if name == "" {
|
||||||
|
if idx >= 0 {
|
||||||
|
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx >= 0 {
|
||||||
|
l.Tiles[idx].Camera = name
|
||||||
|
m.dirty = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(l.Tiles) >= config.MaxTiles {
|
||||||
|
m.setStatus(fmt.Sprintf("layout is full (%d cameras max)", config.MaxTiles), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.Tiles = append(l.Tiles, config.Tile{Camera: name, Col: m.edCol, Row: m.edRow, ColSpan: 1, RowSpan: 1})
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) clearAt() {
|
||||||
|
l := m.curLayout()
|
||||||
|
if idx := m.tileIndexAt(m.edCol, m.edRow); idx >= 0 {
|
||||||
|
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resizeTile grows/shrinks the tile under the cursor by the given span deltas,
|
||||||
|
// keeping it in-bounds and non-overlapping.
|
||||||
|
func (m *model) resizeTile(dCol, dRow int) {
|
||||||
|
idx := m.tileIndexAt(m.edCol, m.edRow)
|
||||||
|
if idx < 0 {
|
||||||
|
m.setStatus("no tile here — assign a camera first", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := &m.curLayout().Tiles[idx]
|
||||||
|
cs, rs := t.Span()
|
||||||
|
newCS, newRS := cs+dCol, rs+dRow
|
||||||
|
if newCS < 1 || newRS < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.regionFree(idx, t.Col, t.Row, newCS, newRS) {
|
||||||
|
m.setStatus("can't resize: would overlap or leave the grid", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.ColSpan, t.RowSpan = newCS, newRS
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resizeGrid changes the base grid dimensions, refusing changes that would push
|
||||||
|
// an existing tile out of bounds.
|
||||||
|
func (m *model) resizeGrid(dCol, dRow int) {
|
||||||
|
cols, rows := m.gridDims()
|
||||||
|
newCols, newRows := cols+dCol, rows+dRow
|
||||||
|
if newCols < 1 || newRows < 1 || newCols > maxGridDim || newRows > maxGridDim {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range m.curLayout().Tiles {
|
||||||
|
cs, rs := t.Span()
|
||||||
|
if t.Col+cs > newCols || t.Row+rs > newRows {
|
||||||
|
m.setStatus("shrink blocked: a tile would fall outside the grid", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.curLayout().Grid = fmt.Sprintf("%dx%d", newCols, newRows)
|
||||||
|
if m.edCol > newCols-1 {
|
||||||
|
m.edCol = newCols - 1
|
||||||
|
}
|
||||||
|
if m.edRow > newRows-1 {
|
||||||
|
m.edRow = newRows - 1
|
||||||
|
}
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
62
internal/tui/render_test.go
Normal file
62
internal/tui/render_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRenderGrid prints a representative editor grid so the layout can be
|
||||||
|
// eyeballed with `go test -run RenderGrid -v ./internal/tui`.
|
||||||
|
func TestRenderGrid(t *testing.T) {
|
||||||
|
lipgloss.SetColorProfile(0) // strip ANSI so the plain grid is readable in logs
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Layouts: []config.Layout{{
|
||||||
|
Name: "main-plus",
|
||||||
|
Grid: "4x3",
|
||||||
|
Tiles: []config.Tile{
|
||||||
|
{Camera: "Front Door", Col: 0, Row: 0, ColSpan: 3, RowSpan: 3},
|
||||||
|
{Camera: "Driveway West", Col: 3, Row: 0},
|
||||||
|
{Camera: "Back Yard", Col: 3, Row: 1},
|
||||||
|
{Camera: "G3 Flex", Col: 3, Row: 2},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
m := &model{cfg: cfg, editLayout: 0, edCol: 3, edRow: 0}
|
||||||
|
t.Log("\n" + m.viewLayoutEdit())
|
||||||
|
|
||||||
|
// A long name (>cellW) must clip with an ellipsis, never produce the
|
||||||
|
// U+FFFD replacement char from slicing a multi-byte rune mid-way.
|
||||||
|
cols, rows := m.gridDims()
|
||||||
|
grid := m.renderGrid(cols, rows)
|
||||||
|
if strings.ContainsRune(grid, '<27>') {
|
||||||
|
t.Errorf("grid contains a broken multi-byte character:\n%s", grid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderPicker prints the camera picker (with the live grid and "already
|
||||||
|
// placed" markers) for a layout that has a long camera name.
|
||||||
|
func TestRenderPicker(t *testing.T) {
|
||||||
|
lipgloss.SetColorProfile(0)
|
||||||
|
cfg := &config.Config{
|
||||||
|
Cameras: []config.Camera{
|
||||||
|
{Name: "Driveway West"}, {Name: "Front Door"}, {Name: "Back Yard"}, {Name: "Garage"},
|
||||||
|
},
|
||||||
|
Layouts: []config.Layout{{
|
||||||
|
Name: "quad", Grid: "2x2",
|
||||||
|
Tiles: []config.Tile{
|
||||||
|
{Camera: "Driveway West", Col: 0, Row: 0},
|
||||||
|
{Camera: "Front Door", Col: 1, Row: 0},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
m := &model{cfg: cfg, editLayout: 0, edCol: 0, edRow: 1, cursor: 1, screen: screenCameraPicker}
|
||||||
|
out := m.viewCameraPicker()
|
||||||
|
if strings.ContainsRune(out, '<27>') {
|
||||||
|
t.Errorf("picker contains a broken character:\n%s", out)
|
||||||
|
}
|
||||||
|
t.Log("\n" + out)
|
||||||
|
}
|
||||||
409
internal/tui/tui.go
Normal file
409
internal/tui/tui.go
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
// Package tui is the interactive Bubble Tea configurator. It is meant to be
|
||||||
|
// run over SSH on the headless Pi: browse cameras, assign them to layout
|
||||||
|
// slots, pick the active layout, discover cameras from UniFi Protect, and save
|
||||||
|
// — signalling the running daemon to reload on save.
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type screen int
|
||||||
|
|
||||||
|
const (
|
||||||
|
screenMenu screen = iota
|
||||||
|
screenCameras
|
||||||
|
screenLayouts
|
||||||
|
screenLayoutEdit
|
||||||
|
screenCameraPicker
|
||||||
|
screenSetActive
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Padding(0, 1)
|
||||||
|
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||||
|
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||||
|
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||||
|
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||||
|
// helpStyle adapts to light/dark terminals so it stays legible on both.
|
||||||
|
helpStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "238", Dark: "251"}).MarginTop(1)
|
||||||
|
// keyStyle accents the key names inside help lines.
|
||||||
|
keyStyle = lipgloss.NewStyle().Bold(true).
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "26", Dark: "81"})
|
||||||
|
// usedStyle marks cameras already placed in the current layout.
|
||||||
|
usedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||||
|
// cursorCellStyle highlights the selected grid cell in the layout editor.
|
||||||
|
cursorCellStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("205"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// hkey formats "<key> <desc>" with the key accented, for help lines.
|
||||||
|
func hkey(key, desc string) string {
|
||||||
|
return keyStyle.Render(key) + " " + desc
|
||||||
|
}
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
cfgPath string
|
||||||
|
cfg *config.Config
|
||||||
|
dirty bool
|
||||||
|
|
||||||
|
screen screen
|
||||||
|
cursor int
|
||||||
|
|
||||||
|
editLayout int // index into cfg.Layouts for edit/picker screens
|
||||||
|
edCol, edRow int // grid-editor cursor position
|
||||||
|
|
||||||
|
status string
|
||||||
|
isError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run loads the config and starts the TUI event loop.
|
||||||
|
func Run(cfgPath string) error {
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu}
|
||||||
|
_, err = tea.NewProgram(m, tea.WithAltScreen()).Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
// ---- messages ----
|
||||||
|
|
||||||
|
type discoverMsg struct {
|
||||||
|
cams []protect.Camera
|
||||||
|
enabled int // count of cameras we turned RTSP on for
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type savedMsg struct {
|
||||||
|
err error
|
||||||
|
reloaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverCmd fetches cameras. When enable is non-empty (e.g. "high"), it also
|
||||||
|
// turns on RTSP in Protect for any camera that lacks an enabled channel.
|
||||||
|
func (m *model) discoverCmd(enable string) tea.Cmd {
|
||||||
|
cfg := m.cfg
|
||||||
|
return func() tea.Msg {
|
||||||
|
if cfg.Controller.Host == "" {
|
||||||
|
return discoverMsg{err: fmt.Errorf("controller.host is not set")}
|
||||||
|
}
|
||||||
|
pw := cfg.Controller.ResolvePassword()
|
||||||
|
if pw == "" {
|
||||||
|
return discoverMsg{err: fmt.Errorf("no controller password available")}
|
||||||
|
}
|
||||||
|
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
||||||
|
if err != nil {
|
||||||
|
return discoverMsg{err: err}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
|
||||||
|
return discoverMsg{err: err}
|
||||||
|
}
|
||||||
|
cams, err := cl.Cameras(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return discoverMsg{err: err}
|
||||||
|
}
|
||||||
|
enabled := 0
|
||||||
|
if enable != "" {
|
||||||
|
names, _ := cl.EnableMissing(ctx, cams, enable)
|
||||||
|
enabled = len(names)
|
||||||
|
}
|
||||||
|
return discoverMsg{cams: cams, enabled: enabled}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- update ----
|
||||||
|
|
||||||
|
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case discoverMsg:
|
||||||
|
return m.handleDiscover(msg)
|
||||||
|
case savedMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.setStatus("save failed: "+msg.err.Error(), true)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.dirty = false
|
||||||
|
if msg.reloaded {
|
||||||
|
m.setStatus("saved and reloaded the running daemon", false)
|
||||||
|
} else {
|
||||||
|
m.setStatus("saved to "+m.cfgPath, false)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyMsg:
|
||||||
|
return m.handleKey(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if msg.err != nil {
|
||||||
|
m.setStatus("discover failed: "+msg.err.Error(), true)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
|
||||||
|
added, updated, skipped := 0, 0, 0
|
||||||
|
for _, cam := range msg.cams {
|
||||||
|
ch := cam.BestEnabledChannel()
|
||||||
|
if ch == nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := cl.StreamURL(ch.RTSPAlias)
|
||||||
|
if ex := m.cameraByID(cam.ID); ex != nil {
|
||||||
|
ex.Name, ex.RTSP = cam.Name, url
|
||||||
|
updated++
|
||||||
|
} else if ex := m.cfg.CameraByName(cam.Name); ex != nil {
|
||||||
|
ex.ID, ex.RTSP = cam.ID, url
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.dirty = true
|
||||||
|
enabledNote := ""
|
||||||
|
if msg.enabled > 0 {
|
||||||
|
enabledNote = fmt.Sprintf(", enabled RTSP on %d", msg.enabled)
|
||||||
|
}
|
||||||
|
m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false)
|
||||||
|
m.screen = screenCameras
|
||||||
|
m.cursor = 0
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
switch m.screen {
|
||||||
|
case screenMenu:
|
||||||
|
return m.updateMenu(msg)
|
||||||
|
case screenCameras:
|
||||||
|
return m.updateCameras(msg)
|
||||||
|
case screenLayouts:
|
||||||
|
return m.updateLayouts(msg)
|
||||||
|
case screenLayoutEdit:
|
||||||
|
return m.updateLayoutEdit(msg)
|
||||||
|
case screenCameraPicker:
|
||||||
|
return m.updateCameraPicker(msg)
|
||||||
|
case screenSetActive:
|
||||||
|
return m.updateSetActive(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// menu -------------------------------------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
menuCameras = iota
|
||||||
|
menuLayouts
|
||||||
|
menuSetActive
|
||||||
|
menuDiscover
|
||||||
|
menuDiscoverEnable
|
||||||
|
menuSave
|
||||||
|
menuQuit
|
||||||
|
)
|
||||||
|
|
||||||
|
var menuItems = []string{
|
||||||
|
menuCameras: "Cameras",
|
||||||
|
menuLayouts: "Layouts",
|
||||||
|
menuSetActive: "Set active layout",
|
||||||
|
menuDiscover: "Discover from UniFi Protect",
|
||||||
|
menuDiscoverEnable: "Discover + enable RTSP (high)",
|
||||||
|
menuSave: "Save",
|
||||||
|
menuQuit: "Quit",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
m.moveCursor(-1, len(menuItems))
|
||||||
|
case "down", "j":
|
||||||
|
m.moveCursor(1, len(menuItems))
|
||||||
|
case "enter", "l", " ":
|
||||||
|
switch m.cursor {
|
||||||
|
case menuCameras:
|
||||||
|
m.screen, m.cursor = screenCameras, 0
|
||||||
|
case menuLayouts:
|
||||||
|
m.screen, m.cursor = screenLayouts, 0
|
||||||
|
case menuSetActive:
|
||||||
|
m.screen, m.cursor = screenSetActive, 0
|
||||||
|
case menuDiscover:
|
||||||
|
m.setStatus("discovering…", false)
|
||||||
|
return m, m.discoverCmd("")
|
||||||
|
case menuDiscoverEnable:
|
||||||
|
m.setStatus("discovering and enabling RTSP…", false)
|
||||||
|
return m, m.discoverCmd("high")
|
||||||
|
case menuSave:
|
||||||
|
return m, m.save()
|
||||||
|
case menuQuit:
|
||||||
|
return m, m.quit()
|
||||||
|
}
|
||||||
|
case "q":
|
||||||
|
return m, m.quit()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cameras ----------------------------------------------------------
|
||||||
|
|
||||||
|
func (m *model) updateCameras(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
n := len(m.cfg.Cameras)
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "backspace", "h":
|
||||||
|
m.screen, m.cursor = screenMenu, 0
|
||||||
|
case "up", "k":
|
||||||
|
m.moveCursor(-1, n)
|
||||||
|
case "down", "j":
|
||||||
|
m.moveCursor(1, n)
|
||||||
|
case "d":
|
||||||
|
if n > 0 {
|
||||||
|
m.cfg.Cameras[m.cursor].Disabled = !m.cfg.Cameras[m.cursor].Disabled
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
case "x":
|
||||||
|
if n > 0 {
|
||||||
|
m.cfg.Cameras = append(m.cfg.Cameras[:m.cursor], m.cfg.Cameras[m.cursor+1:]...)
|
||||||
|
if m.cursor >= len(m.cfg.Cameras) && m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// layouts ----------------------------------------------------------
|
||||||
|
|
||||||
|
func (m *model) updateLayouts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
n := len(m.cfg.Layouts)
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "backspace", "h":
|
||||||
|
m.screen, m.cursor = screenMenu, 0
|
||||||
|
case "up", "k":
|
||||||
|
m.moveCursor(-1, n)
|
||||||
|
case "down", "j":
|
||||||
|
m.moveCursor(1, n)
|
||||||
|
case "enter", "l":
|
||||||
|
if n > 0 {
|
||||||
|
m.editLayout = m.cursor
|
||||||
|
m.migrateToTiles(m.editLayout)
|
||||||
|
m.edCol, m.edRow = 0, 0
|
||||||
|
m.screen, m.cursor = screenLayoutEdit, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// (grid-editor update logic lives in gridedit.go)
|
||||||
|
|
||||||
|
// camera picker ----------------------------------------------------
|
||||||
|
|
||||||
|
// pickerOptions is "(empty)" followed by enabled camera names.
|
||||||
|
func (m *model) pickerOptions() []string {
|
||||||
|
opts := []string{"(empty)"}
|
||||||
|
for _, c := range m.cfg.Cameras {
|
||||||
|
if c.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
opts = append(opts, c.Name)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) updateCameraPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
opts := m.pickerOptions()
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "backspace", "h":
|
||||||
|
m.screen, m.cursor = screenLayoutEdit, 0
|
||||||
|
case "up", "k":
|
||||||
|
m.moveCursor(-1, len(opts))
|
||||||
|
case "down", "j":
|
||||||
|
m.moveCursor(1, len(opts))
|
||||||
|
case "enter", "l":
|
||||||
|
choice := ""
|
||||||
|
if m.cursor > 0 {
|
||||||
|
choice = opts[m.cursor]
|
||||||
|
}
|
||||||
|
m.assignCamera(choice)
|
||||||
|
m.screen, m.cursor = screenLayoutEdit, 0
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// set active -------------------------------------------------------
|
||||||
|
|
||||||
|
func (m *model) updateSetActive(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
n := len(m.cfg.Layouts)
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "backspace", "h":
|
||||||
|
m.screen, m.cursor = screenMenu, 0
|
||||||
|
case "up", "k":
|
||||||
|
m.moveCursor(-1, n)
|
||||||
|
case "down", "j":
|
||||||
|
m.moveCursor(1, n)
|
||||||
|
case "enter", "l":
|
||||||
|
if n > 0 {
|
||||||
|
m.cfg.ActiveLayout = m.cfg.Layouts[m.cursor].Name
|
||||||
|
m.dirty = true
|
||||||
|
m.setStatus("active layout set to "+m.cfg.ActiveLayout, false)
|
||||||
|
m.screen, m.cursor = screenMenu, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
func (m *model) moveCursor(delta, n int) {
|
||||||
|
if n == 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.cursor = (m.cursor + delta + n) % n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) cameraByID(id string) *config.Camera {
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range m.cfg.Cameras {
|
||||||
|
if m.cfg.Cameras[i].ID == id {
|
||||||
|
return &m.cfg.Cameras[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) setStatus(s string, isErr bool) {
|
||||||
|
m.status, m.isError = s, isErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) save() tea.Cmd {
|
||||||
|
cfgPath, cfg := m.cfgPath, m.cfg
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := config.Save(cfgPath, cfg); err != nil {
|
||||||
|
return savedMsg{err: err}
|
||||||
|
}
|
||||||
|
// Best-effort: tell a running daemon to reload.
|
||||||
|
_, err := ipc.Send(ipc.Request{Cmd: "reload"})
|
||||||
|
return savedMsg{reloaded: err == nil}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) quit() tea.Cmd {
|
||||||
|
return tea.Quit
|
||||||
|
}
|
||||||
267
internal/tui/view.go
Normal file
267
internal/tui/view.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *model) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
switch m.screen {
|
||||||
|
case screenMenu:
|
||||||
|
b.WriteString(m.viewMenu())
|
||||||
|
case screenCameras:
|
||||||
|
b.WriteString(m.viewCameras())
|
||||||
|
case screenLayouts:
|
||||||
|
b.WriteString(m.viewLayouts())
|
||||||
|
case screenLayoutEdit:
|
||||||
|
b.WriteString(m.viewLayoutEdit())
|
||||||
|
case screenCameraPicker:
|
||||||
|
b.WriteString(m.viewCameraPicker())
|
||||||
|
case screenSetActive:
|
||||||
|
b.WriteString(m.viewSetActive())
|
||||||
|
}
|
||||||
|
if m.status != "" {
|
||||||
|
style := okStyle
|
||||||
|
if m.isError {
|
||||||
|
style = errStyle
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n" + style.Render(m.status))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) list(title string, rows []string, help string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render(title) + "\n\n")
|
||||||
|
if len(rows) == 0 {
|
||||||
|
b.WriteString(dimStyle.Render(" (nothing here yet)") + "\n")
|
||||||
|
}
|
||||||
|
for i, row := range rows {
|
||||||
|
cursor := " "
|
||||||
|
line := row
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = cursorStyle.Render("▸ ")
|
||||||
|
line = cursorStyle.Render(row)
|
||||||
|
}
|
||||||
|
b.WriteString(cursor + line + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(helpStyle.Render(help))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) viewMenu() string {
|
||||||
|
dirty := ""
|
||||||
|
if m.dirty {
|
||||||
|
dirty = dimStyle.Render(" (unsaved changes)")
|
||||||
|
}
|
||||||
|
rows := make([]string, len(menuItems))
|
||||||
|
copy(rows, menuItems)
|
||||||
|
rows[menuSave] = fmt.Sprintf("Save%s", dirty)
|
||||||
|
return m.list("rtsp-streamer configurator", rows,
|
||||||
|
"↑/↓ move · enter select · q quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) viewCameras() string {
|
||||||
|
rows := make([]string, 0, len(m.cfg.Cameras))
|
||||||
|
for _, c := range m.cfg.Cameras {
|
||||||
|
state := ""
|
||||||
|
if c.Disabled {
|
||||||
|
state = dimStyle.Render(" [disabled]")
|
||||||
|
}
|
||||||
|
src := dimStyle.Render(truncate(c.RTSP, 48))
|
||||||
|
rows = append(rows, fmt.Sprintf("%-24s %s%s", c.Name, src, state))
|
||||||
|
}
|
||||||
|
return m.list(fmt.Sprintf("Cameras (%d)", len(m.cfg.Cameras)), rows,
|
||||||
|
"d toggle disabled · x delete · esc back")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) viewLayouts() string {
|
||||||
|
rows := make([]string, 0, len(m.cfg.Layouts))
|
||||||
|
for _, l := range m.cfg.Layouts {
|
||||||
|
active := ""
|
||||||
|
if l.Name == m.cfg.ActiveLayout {
|
||||||
|
active = okStyle.Render(" ●active")
|
||||||
|
}
|
||||||
|
rows = append(rows, fmt.Sprintf("%-16s %-6s %d cameras%s", l.Name, l.Grid, len(l.EffectiveTiles()), active))
|
||||||
|
}
|
||||||
|
return m.list("Layouts", rows, "enter edit · esc back")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cell dimensions for the ASCII preview.
|
||||||
|
const (
|
||||||
|
cellW = 10 // inner width in characters
|
||||||
|
cellH = 2 // inner height in text rows
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *model) viewLayoutEdit() string {
|
||||||
|
l := m.cfg.Layouts[m.editLayout]
|
||||||
|
cols, rows := m.gridDims()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
title := fmt.Sprintf("Edit %q — %dx%d grid, %d/%d cameras", l.Name, cols, rows, len(l.Tiles), 16)
|
||||||
|
b.WriteString(titleStyle.Render(title) + "\n\n")
|
||||||
|
b.WriteString(m.renderGrid(cols, rows))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(strings.Join([]string{
|
||||||
|
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"),
|
||||||
|
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
|
||||||
|
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
|
||||||
|
}, "\n"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderGrid draws the base grid with tiles, camera labels, continuation marks
|
||||||
|
// for spanned cells, and a highlighted cursor cell — a live picture of the wall.
|
||||||
|
func (m *model) renderGrid(cols, rows int) string {
|
||||||
|
l := m.cfg.Layouts[m.editLayout]
|
||||||
|
horiz := "+" + strings.Repeat(strings.Repeat("-", cellW)+"+", cols)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
b.WriteString(horiz + "\n")
|
||||||
|
for line := 0; line < cellH; line++ {
|
||||||
|
b.WriteString("|")
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
b.WriteString(m.renderCell(l, c, r, line) + "|")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(horiz)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCell returns one text line of one grid cell. It first builds a plain
|
||||||
|
// string of exactly cellW characters, then styles the whole cell, so ANSI
|
||||||
|
// escapes never throw off column alignment.
|
||||||
|
func (m *model) renderCell(l config.Layout, c, r, line int) string {
|
||||||
|
idx := m.tileIndexAtLayout(l, c, r)
|
||||||
|
isCursor := c == m.edCol && r == m.edRow
|
||||||
|
|
||||||
|
plain := strings.Repeat(" ", cellW)
|
||||||
|
dim := false
|
||||||
|
if idx >= 0 {
|
||||||
|
t := l.Tiles[idx]
|
||||||
|
isOrigin := c == t.Col && r == t.Row
|
||||||
|
switch {
|
||||||
|
case isOrigin && line == 0:
|
||||||
|
label := t.Camera
|
||||||
|
if label == "" {
|
||||||
|
label = "(no cam)"
|
||||||
|
}
|
||||||
|
plain = padTo(truncate(label, cellW), cellW)
|
||||||
|
case isOrigin && line == 1:
|
||||||
|
cs, rs := t.Span()
|
||||||
|
if cs > 1 || rs > 1 {
|
||||||
|
plain = padTo(fmt.Sprintf("%dx%d", cs, rs), cellW)
|
||||||
|
}
|
||||||
|
dim = true
|
||||||
|
default: // continuation cell of a spanned tile
|
||||||
|
plain = center("·", cellW)
|
||||||
|
dim = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case isCursor:
|
||||||
|
return cursorCellStyle.Render(plain)
|
||||||
|
case dim:
|
||||||
|
return dimStyle.Render(plain)
|
||||||
|
default:
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tileIndexAtLayout is tileIndexAt against an explicit layout value (used by
|
||||||
|
// the renderer, which holds a copy).
|
||||||
|
func (m *model) tileIndexAtLayout(l config.Layout, col, row int) int {
|
||||||
|
for i, t := range l.Tiles {
|
||||||
|
cs, rs := t.Span()
|
||||||
|
if col >= t.Col && col < t.Col+cs && row >= t.Row && row < t.Row+rs {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) viewCameraPicker() string {
|
||||||
|
l := m.cfg.Layouts[m.editLayout]
|
||||||
|
cols, rows := m.gridDims()
|
||||||
|
used := m.usedCameras()
|
||||||
|
opts := m.pickerOptions()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render(fmt.Sprintf("Assign to %q — cell (col %d, row %d)", l.Name, m.edCol, m.edRow)) + "\n\n")
|
||||||
|
// Show the live grid so you can see what's already placed while choosing.
|
||||||
|
b.WriteString(m.renderGrid(cols, rows) + "\n\n")
|
||||||
|
|
||||||
|
for i, o := range opts {
|
||||||
|
cursor := " "
|
||||||
|
var label string
|
||||||
|
switch {
|
||||||
|
case i == 0: // the "(empty)" option
|
||||||
|
label = dimStyle.Render(o)
|
||||||
|
case used[o]:
|
||||||
|
label = o + usedStyle.Render(" ● already placed")
|
||||||
|
default:
|
||||||
|
label = o
|
||||||
|
}
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = cursorStyle.Render("▸ ")
|
||||||
|
if i != 0 && !used[o] {
|
||||||
|
label = cursorStyle.Render(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(cursor + label + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + hkey("enter", "choose") + " " + hkey("(empty)", "clears cell") + " " + hkey("esc", "back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) viewSetActive() string {
|
||||||
|
rows := make([]string, 0, len(m.cfg.Layouts))
|
||||||
|
for _, l := range m.cfg.Layouts {
|
||||||
|
mark := ""
|
||||||
|
if l.Name == m.cfg.ActiveLayout {
|
||||||
|
mark = okStyle.Render(" ●current")
|
||||||
|
}
|
||||||
|
rows = append(rows, fmt.Sprintf("%-16s %s%s", l.Name, l.Grid, mark))
|
||||||
|
}
|
||||||
|
return m.list("Set active layout", rows, "enter select · esc back")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The string helpers below count runes, not bytes, so multi-byte characters
|
||||||
|
// (e.g. the ellipsis) are never sliced mid-character into invalid UTF-8.
|
||||||
|
|
||||||
|
// padTo left-justifies s in a field of n columns (single-width runes assumed).
|
||||||
|
func padTo(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) >= n {
|
||||||
|
return string(r[:n])
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", n-len(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// center centers s within n columns.
|
||||||
|
func center(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) >= n {
|
||||||
|
return string(r[:n])
|
||||||
|
}
|
||||||
|
left := (n - len(r)) / 2
|
||||||
|
return strings.Repeat(" ", left) + s + strings.Repeat(" ", n-len(r)-left)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate shortens s to at most n columns, using an ellipsis when it clips.
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if n <= 1 {
|
||||||
|
return string(r[:n])
|
||||||
|
}
|
||||||
|
return string(r[:n-1]) + "…"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user