Files
RTSP-Streamer/README.md
Levi Woodard 98ddf1645c Smoother daemon: no-op reloads, batched placement, muted audio, graceful teardown
- Skip stream restarts when a reload resolves to the identical wall
  (same URLs, tile geometry, player settings) — saving an unrelated
  config edit no longer blanks the screen.
- Replace per-tile placement polling (a swaymsg fork per tile per second,
  plus 150ms get_tree polling per tile at startup) with one shared loop:
  a single get_tree snapshot per tick, re-placing only drifted windows,
  relaxing to a 2s tick once settled.
- Mute streams by default (--no-audio) to skip an audio decoder per
  stream; opt back in with player.audio: true.
- Graceful, parallel mpv teardown via cmd.Cancel/WaitDelay, fixing the
  double-Wait race between Stop and Supervise and cutting worst-case
  layout switches from ~2s x N streams to ~2s total.
- status: probe mpv IPC outside the daemon mutex so a slow probe can't
  block layout switches.
- View sync: reuse the Protect session across ticks (re-login only on
  expiry) and pick up view_refresh_seconds changes without a restart.
- Health strikes keyed by player, not slot, so they never carry across
  layout switches; prune stale entries.
- New `rtsp-streamer reload` CLI; fix dead sort in `layout ls`; back off
  on persistent control-socket accept errors; fsync config before the
  atomic rename (SD-card power-cut safety); bump IPC client deadline;
  gofmt stragglers.

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

15 KiB
Raw Blame History

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 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:

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

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

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 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.

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

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 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:

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):

# ~/.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):

# 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

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:

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:
    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

  • 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 viewsviews 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, 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