Files
RTSP-Streamer/README.md
Levi Woodard 840324825b Bound live-stream latency drift with periodic live-edge resync
Over hours the wall drifted ~15s behind real-time: the Pi decodes a hair
slower than real-time, and since RTSP-over-TCP delivers every byte, that
small deficit buffers up instead of being dropped — and a live stream
can't be seeked back to the live edge.

Add player.resync_seconds (0 = off): the daemon reconnects each stream to
the live edge on that interval via mpv loadfile-replace over IPC, cycling
one tile at a time so only a single tile ever blips. Reusing the running
mpv process means no window teardown. At 600s this caps drift to a couple
seconds. config.example.yaml ships it at 600; README documents the knob
and the "also lighten decode load" caveat.

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

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

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

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.
  • Latency slowly creeping up over hours (e.g. seconds of drift after a couple hours) means the Pi is decoding a hair behind real-time, so RTSP-over- TCP quietly buffers the deficit — and a live stream can't be seeked back to "now." Set player.resync_seconds (e.g. 600) so the daemon reconnects each stream to the live edge on that interval, staggered one tile at a time, which caps the drift to a few seconds. If drift is large, also lighten decode load (low substreams, cap max_fps) so the Pi keeps up between resyncs.

Troubleshooting

  • A stream keeps going unhealthy / flappingrtsp-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 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, 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