Files
RTSP-Streamer/README.md
Levi Woodard ed9814ab4e Reconnect streams in-process; fix for_window IPC rules
Root cause of the periodic drops (found via the new logs): mpv was
exiting cleanly (err=nil, exit 0) with its status line at 100% — i.e.
end-of-stream. UniFi Protect closes some cameras' RTSP connections every
20-40s; with --keep-open=no --idle=no mpv treated that as the file
ending, exited, and the supervisor relaunched it ~3s later (the visible
drop).

- Add --loop-file=inf so mpv reopens the stream the instant it EOFs,
  in the same process and window: sub-second recovery, no teardown,
  no supervisor bounce. The supervisor now only fires for real crashes.
- Clean up the logged exit "reason": strip ANSI escapes and skip mpv's
  transient A/V status prints, so a genuine error surfaces instead of
  "\x1b[KV: 00:00:35 / 00:00:35 (100%)". Tests cover the parsing.

Also fold in the for_window IPC fix: sway's IPC parser splits comma-
joined command lists at the top level and does not fold the continuation
into for_window (unlike the config-file parser), so the map-time
placement rules errored with "Only views can have borders" and never
installed. Register each command separately; drop the redundant
border none (the kiosk config already sets default_border none).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:54:02 -05:00

341 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 reload # apply hand-edits to the config live
rtsp-streamer restart # re-exec the daemon in place (picks up a new
# binary; no reboot)
rtsp-streamer logs -n 80 # recent daemon activity incl. mpv exit reasons
rtsp-streamer version # baked-in git commit / build date
```
Reloads (and TUI saves) are minimal-impact: the daemon compares the resolved
wall — stream URLs, tile geometry, player settings — against what's already
running and leaves the streams untouched when nothing material changed, so
saving an unrelated edit never blanks the screen.
## 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 <repo> ~/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 deploy
```
`make deploy` builds natively, copies to `/usr/local/bin` (auto-sudo — run it
*without* `sudo` so `go build` keeps your `PATH`), then runs
`rtsp-streamer restart`, which tells the running daemon to **re-exec itself in
place**: same process, same sway session, now running the new binary — no
reboot, no duplicate sessions. Confirm what's live with `rtsp-streamer version`.
`restart` works because the daemon is launched under a small relaunch loop in
the sway config, so it also recovers on its own if it ever crashes. (Older
installs that predate this launcher need their sway config refreshed — re-run
`deploy/install.sh` or copy `deploy/sway/config` to
`/etc/rtsp-streamer/sway/config` once, then reboot.) A full `sudo reboot` is
still fine and never wrong.
## 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.
- **Audio is off by default** (`--no-audio`), which skips one audio decoder per
stream. Set `player.audio: true` if you actually want camera sound.
## Troubleshooting
- **A stream keeps going unhealthy / flapping** — `rtsp-streamer logs` shows the
daemon's recent activity, including the *reason* each mpv exited (its stderr
tail: connection refused, unsupported codec, 401, etc.). This is the first
thing to check when one tile restarts repeatedly. A camera whose `video-codec`
is `hevc` can't hardware-decode on a Pi 4 and often stutters/exits under load —
switch it to H.264 in Protect or give the tile the `low` substream.
UniFi Protect also *closes* some cameras' RTSP connections periodically (every
20-40s on certain models), which mpv sees as a clean end-of-stream. mpv is run
with `--loop-file=inf` so it reconnects in-process (sub-second, no window
teardown) rather than exiting and being relaunched — so this is normally
invisible. If a camera still blips on reconnect, it's dropping unusually often;
check its codec/bitrate in Protect.
- **`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 <your-user>`
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).
- Optional git tags so `version` shows a release rather than a hash.
## Layout
```
cmd/rtsp-streamer/ CLI (cobra): daemon, discover, layout, reload, restart, logs, 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
```