Diagnosis: individual tiles flickered when their stream died and mpv relaunched (confirmed via status: a PID dropping to 0 and coming back). The restarted window mapped at the video's native size wherever sway dropped it, and the corrective loop only snapped it into its cell on the next tick — up to 2s later on the relaxed cadence. Fix, at the source instead of racing the map: - Pre-install a per-slot for_window rule (matched on each mpv's unique window title, anchored so slot-1 never matches slot-10) so sway positions and sizes the window synchronously the moment it maps. - Pass --geometry=WxH per tile so mpv opens at the tile size rather than resizing itself to the video's native size on load. The placeLoop remains as a corrective backstop for mid-life drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
mpvprocess 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/mpvmosaic, 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-safeuses the Pi 4's V4L2/DRM H.264 decoder. - Stateful config. A single hand-editable
config.yaml, or edit it live withrtsp-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:
swayandmpv- optional: a terminal like
footfor 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/slogneeds 1.21+). Install a current Go into~/.local/goand put it first onPATH— do not rely on the systemgo: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,
ddisable,xdelete. - 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 enterassign a camera to the cell (pick(empty)to clear) ·cclearqcycle 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/Hwider/narrower,J/Ktaller/shorter - resize the base grid:
]/[add/remove a column,}/{add/remove a row escback
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: nomeans software decoding.--hwdec=auto-safeoften won't pick the Pi's decoder — setplayer.hwdec: v4l2m2m-copyexplicitly. - H.265 cameras can't hardware-decode on a Pi 4. If
video-codecishevc, 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
lowquality (per-tileqin the TUI, orquality: lowin 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. Cappingplayer.max_fpstrims render load too. - Audio is off by default (
--no-audio), which skips one audio decoder per stream. Setplayer.audio: trueif you actually want camera sound.
Troubleshooting
statuscan't reach the daemon (control.sock: no such file) — the wall is running as a different user than your SSH session (checkps -o user= -p "$(pgrep -x sway)"vsid). Re-runinstall.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 rebootfor 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=noand the daemon re-squares tiles every second); if it does, confirmrtsp-streamer versionis a recent build. login failed/403 settings:editon 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; checkecho $SWAYSOCK.- Build error
package log/slog is not in GOROOT— you're on Debian's Go 1.19; install Go 1.23 into~/.local/goand prepend it toPATH(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 incameras: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 importreproduces the cameras and an even grid today; mapping Protect'slayoutpreset 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
versionshows 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