# 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+**. You can build on your dev machine and copy the binary, or build on the Pi itself. > ⚠️ Debian / Raspberry Pi OS ships **Go 1.19** via `apt`, which is too old > (`log/slog` needs 1.21+). Install a current Go into `~/.local/go` and put it > first on `PATH` — do **not** rely on the system `go`: > ```sh > curl -sSLO https://go.dev/dl/go1.23.4.linux-arm64.tar.gz # match uname -m > rm -rf ~/.local/go && mkdir -p ~/.local && tar -C ~/.local -xzf go1.23.4.linux-arm64.tar.gz > echo 'export PATH=$HOME/.local/go/bin:$PATH' >> ~/.profile && export PATH=$HOME/.local/go/bin:$PATH > go version # must show 1.23+, not 1.19 > ``` ## 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 make install # build native + copy to /usr/local/bin (auto-sudo) make deploy # install + restart the kiosk session rtsp-streamer version # prints the baked-in git commit / build date ``` ## 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 | ... ``` - **mouse (over SSH):** click a cell to assign a camera; **press on a tile and drag to resize** its span, tmux-style. Click to select on the menus/lists too. - move cursor: arrows or `h`/`j`/`k`/`l` - `enter` assign a camera to the cell (pick `(empty)` to clear) · `c` clear - `q` cycle this tile's **stream quality** (auto → high → low …); the choice is shown in the tile. Small tiles → low substream, big tiles → high. - 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 (or click), `enter` selects, `esc` back, `q` quits. ### Stream quality per tile Discovery records every RTSP-enabled channel per camera (`streams: {high, low, …}`), and each tile picks which to pull. Put big tiles on `high` and small tiles on the `low` substream — that's the main lever for keeping a dense wall smooth on a Pi 4. Enable both channels at once with `discover --enable-rtsp=high,low` (or the TUI's "Discover + enable RTSP (hi+lo)"). ## 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) rtsp-streamer version # baked-in git commit / build date ``` ## UniFi Protect live views Copy a saved Protect "Live View" (its cameras and grid) straight into a layout: ```sh rtsp-streamer views ls # list the controller's live views rtsp-streamer views import "All Cameras" # import one as a layout rtsp-streamer views import --all # import every view rtsp-streamer views dump # raw view JSON (for tuning odd layouts) ``` Cameras are matched by Protect id, so run `discover` first. Grid size is inferred from the slot count (asymmetric "1 big + N" presets land as an even grid for now — send me `views dump` output to map exact sizing). An imported layout is **linked** to its view (`protect_view:` in the config). ### Auto-resync Set `view_refresh_seconds` (e.g. `300`) and the daemon re-pulls the active layout's linked view on that interval — edits you make in Protect (add/remove a camera, reorder) show up on the wall automatically. This is the **one** feature that needs controller credentials **at runtime**, so export the password for the daemon (in the kiosk user's `~/.bash_profile`, before the sway launcher): ```sh # ~/.bash_profile export RTSP_STREAMER_PASSWORD='…' ``` Leave `view_refresh_seconds` at 0 (default) and the wall stays credential-free. ## Deploy to a Pi (boot straight to the wall) First-time setup (run `install.sh` **as the user you'll run the wall as** — pass your own username; letting it default to a `kiosk` user is a common footgun, because the daemon's control socket lives in that user's runtime dir and must match your SSH user): ```sh # get the binary onto the Pi — build there (needs Go 1.23, see Build), or copy: git clone ~/RTSP-Streamer && cd ~/RTSP-Streamer && make build sudo ./deploy/install.sh "$USER" ``` `install.sh` installs the binary to `/usr/local/bin`, drops the sway config at `/etc/rtsp-streamer/sway/config`, adds you to the `video/render/input` groups, enables **tty1 autologin**, and appends a launcher to `~/.bash_profile` that starts sway on the physical console only (SSH still gets a normal shell). The running wall needs **no controller password** — the config already holds the resolved stream URLs; the password is only for `discover`. Then `sudo reboot` and the wall comes up on boot. ### Updating ```sh git pull && make install && sudo reboot ``` `make install` builds natively and copies to `/usr/local/bin` (auto-sudo — run it *without* `sudo` so `go build` keeps your `PATH`). Use **`sudo reboot`**, not `systemctl restart getty@tty1`: a getty restart tends to leave the old sway + daemon running alongside the new one (duplicate mpv, windows fighting). Confirm what's live with `rtsp-streamer version`. ## 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. ## Performance & latency (Pi 4) The Pi 4 hardware-decodes **H.264 only** (no HEVC). If streams lag or CPU is pegged, work through: - **Confirm hardware decode is on.** Probe a running player: ```sh S=$XDG_RUNTIME_DIR/rtsp-streamer/mpv-slot-0.sock printf '{"command":["get_property_string","hwdec-current"]}\n' | socat - "$S" printf '{"command":["get_property_string","video-codec"]}\n' | socat - "$S" ``` `hwdec-current: no` means software decoding. `--hwdec=auto-safe` often won't pick the Pi's decoder — set `player.hwdec: v4l2m2m-copy` explicitly. - **H.265 cameras can't hardware-decode on a Pi 4.** If `video-codec` is `hevc`, set that camera to H.264 in Protect, or use its (often H.264) low substream. - **Use substreams for dense walls.** Full-res (4K/4MP) × many tiles exceeds the decoder's budget → fallback to software → growing latency. Give small tiles the `low` quality (per-tile `q` in the TUI, or `quality: low` in config). - **Latency drift** is handled by `--framedrop=decoder+vo` + low-delay demuxer flags (built in), so a briefly-behind stream drops frames to catch up instead of accumulating a backlog. Capping `player.max_fps` trims render load too. ## Troubleshooting - **`status` can't reach the daemon** (`control.sock: no such file`) — the wall is running as a *different user* than your SSH session (check `ps -o user= -p "$(pgrep -x sway)"` vs `id`). Re-run `install.sh ` and reboot so autologin/sway/daemon all run as you. - **Duplicate windows / one stream floating loose** — two sway sessions are running (from a getty restart). `sudo reboot` for one clean session. - **Blank/black tile** — verify the stream directly: `mpv --rtsp-transport=tcp 'rtsps://HOST:7441/ALIAS?enableSrtp'`. If it fails, RTSP isn't enabled for that camera (`discover --enable-rtsp=high,low`). - **A tile's video is cut off / overflows** — should not happen (mpv is told `--keepaspect-window=no` and the daemon re-squares tiles every second); if it does, confirm `rtsp-streamer version` is a recent build. - **`login failed` / `403 settings:edit` on enable** — use a *local* Protect account; enabling RTSP needs an **admin** account (view-only 403s). - **`swaymsg not found` / windows not placed** — the daemon must run inside the sway session; check `echo $SWAYSOCK`. - **Build error `package log/slog is not in GOROOT`** — you're on Debian's Go 1.19; install Go 1.23 into `~/.local/go` and prepend it to `PATH` (see Build). ## 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. ## Roadmap - **Exact sizing for asymmetric Protect views** — `views import` reproduces the cameras and an even grid today; mapping Protect's `layout` preset id to spanning tiles (1-big-plus-N, etc.) is the next refinement. - Slot cycling — Protect slots can rotate through multiple cameras; we take the first. Honor `cycleMode`/`cycleInterval`. - Layout rotation / cycling on a timer (the daemon already re-tiles on demand). - TUI create / delete / rename layouts (today the TUI only *edits* existing ones; new layouts are added in YAML). - `rtsp-streamer reload` CLI (so hand-edits apply live without the TUI or a restart); optional git tags so `version` shows a release rather than a hash. ## Layout ``` cmd/rtsp-streamer/ CLI (cobra): daemon, discover, layout, status, tui, config, version internal/config/ YAML load/save/validate, schema (tiles, streams), XDG paths internal/protect/ UniFi Protect client (login, bootstrap, enable RTSP, URLs) internal/player/ one supervised mpv per stream, JSON IPC, restart/backoff internal/compositor/ sway control + tile geometry internal/daemon/ orchestrator + control socket + health loop internal/ipc/ control-socket protocol shared by daemon and CLI/TUI internal/tui/ Bubble Tea configurator (grid editor, mouse, quality) deploy/ sway kiosk config, autologin, install.sh ```