Three separate faults made cameras "not load" and the clock misbehave. Clock rendered as nothing, or flashed ~200ms/second. The time was drawn as an ASS osd-overlay pushed over mpv IPC, but on mpv 0.35 + Mesa/V3D + sway an OSD overlay is rendered only on the frame where its content *changes*. Every layer reports success while this happens (mpv returns error:success, vo-configured is true, and sway reports the window visible at the right rect), so it looks like a stacking or font bug and is neither. Ruled out: pushing at 20Hz (identical content is ignored, so it still only redrew when the second flipped), osd-msg1, show-text, and --pause (mpv stops redrawing entirely). Fonts were never the issue. The time is now baked into every frame by a drawtext filter re-reading a small file the ticker rewrites once a second, with two constraints that cost real time to find and are pinned by tests: - The canvas alpha must be > 0. A fully transparent canvas (black@0.0 with --alpha=yes) makes the glyphs inherit alpha 0 and the compositor draws nothing -- this was the original invisible clock. New clock background_opacity (default 0.45) is clamped in config *and* in args() so no code path can produce an invisible clock. - Readahead must be off. drawtext stamps the time when a frame is *generated*, so buffering ahead makes the visible clock lag by the readahead and swallows text-file updates entirely. Since the text now arrives through a file, the clock needs no IPC socket: dropped --input-ipc-server, the ipcPath field, and the stale-socket removal. assEscape goes with the ASS path. `views import` produced layouts with holes. viewmap derived the grid from the slot count alone and ignored Protect's `layout` field, so Protect's asymmetric 8-camera preset (four 2x2 tiles plus a right column of four 1x1) landed as 8 tiles in a 3x3 grid -- the bottom-right cell was simply empty and rendered as a blank rectangle. That preset is now mapped exactly; other counts keep the uniform GridForSlots fallback rather than guessing at presets I have not observed. Import also warns when a mapping would leave empty cells or references a camera missing from the config, so a silent hole cannot reach the screen again. Also: - clock.corner gains bottom-center and top-center (centered horizontally, Margin still applies vertically). - placeClock no longer re-issues `resize set` every tick. Re-asserting geometry on a correctly-sized window makes sway send a configure event, which makes mpv reallocate buffers and blank for a frame. New compositor.Raise re-asserts z-order only, which is all the 2s tick needs; geometry is re-placed only when it has actually drifted. - README documents why the clock is drawn this way, the preset table and how to add another from `views dump`, and three troubleshooting entries for failure modes that all look like bugs: a blank tile whose mpv is running (a stale camera entry -- re-adopting a camera in Protect assigns a new id and often a slightly different name, and `discover` never prunes), cameras in `cameras:` not being on screen (only the active layout's tiles stream), and black bars inside tiles (non-16:9 grid cells; --panscan=1.0 crops to fill instead). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYhTnkp7VzJ67THeicgfAQ
22 KiB
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 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.
Clock overlay
An optional always-on-top clock can be rendered at a screen edge:
clock:
enabled: true
timezone: America/Denver # IANA name; "Local" uses the system zone. DST auto.
format: "15:04:05" # Go time layout (24-hour w/ seconds)
corner: bottom-center # bottom-right | bottom-left | top-right | top-left
# | bottom-center | top-center
background_opacity: 0.45 # alpha of the backing box; must be > 0 (see below)
It's a tiny mpv window (no extra dependencies) drawing the time as white text
with a black outline over a dimmed backing box, so it stays readable over both
bright (day) and dark (night) camera scenes without measuring the picture. The
time is formatted in the configured timezone via Go's zone database, so it's
correct regardless of the host clock's zone and handles DST on its own. The
daemon keeps it positioned and on top across layout switches. Size
(width/height), font_size, and edge margin are configurable;
enable/disable or retune it live with an edit plus rtsp-streamer reload.
The *-center positions center the window horizontally and ignore margin on
that axis; margin still applies vertically.
Why the clock is drawn the way it is
The time is baked into every video frame by an ffmpeg drawtext filter that
re-reads a small text file ($XDG_RUNTIME_DIR/rtsp-streamer/clock-text.txt),
which the daemon rewrites once a second. That looks roundabout — pushing an ASS
osd-overlay over mpv's IPC would be the obvious approach — but on mpv 0.35 +
Mesa/V3D + sway an OSD overlay is rendered only on the frame where its content
changes, so an IPC-driven clock is visible for roughly 200ms per second and
reads as a flashing clock. Every layer reports success while this happens
(error: success from mpv, vo-configured: true, sway reporting the window
visible at the right rect), so it looks like a stacking or font bug and is
neither. Two constraints follow, and both are covered by tests:
background_opacitymust be greater than zero. A fully transparent canvas (color=c=black@0.0with--alpha=yes) makes the drawn glyphs inherit alpha 0, and the compositor shows nothing at all. Values of0or below — including the field being absent — are clamped to the default.- The canvas must not be buffered ahead (
--cache=no,--demuxer-readahead-secs=0).drawtextstamps the time when a frame is generated, so reading ahead makes the displayed clock lag by the readahead and swallows text-file updates entirely.
Because the text arrives through a file, the clock window needs no IPC socket.
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. An imported layout is
linked to its view (protect_view: in the config).
Protect's arrangement for a view is not always an even grid. Known asymmetric presets are mapped exactly; any other slot count falls back to a near-square grid of equal tiles:
| Slots | Result |
|---|---|
| 8 | 5x4 — four 2x2 tiles plus a right-hand column of four 1x1 tiles |
| anything else | near-square uniform grid (GridForSlots) |
To add another preset, run views dump, note the view's layout value and slot
order, and add an entry to presets in internal/viewmap/viewmap.go. Slot order
in the dump is the order tiles are filled.
Import warns whenever a mapping would leave grid cells empty (they show as blank rectangles on the wall) or references a camera that isn't in the config:
! Office View: 1 of 9 cells in the 3x3 grid are empty and will show as blank areas
! Office View: slot 4 camera 6a65…c6d not in config (run `discover`)
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: 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. - 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, capmax_fps) so the Pi keeps up between resyncs.
Troubleshooting
- A stream keeps going unhealthy / flapping —
rtsp-streamer logsshows 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 whosevideo-codecishevccan't hardware-decode on a Pi 4 and often stutters/exits under load — switch it to H.264 in Protect or give the tile thelowsubstream. 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=infso 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. 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 stays blank but its mpv process is running — the camera entry is
stale. Re-adopting a camera in Protect gives it a new id and often a slightly
different name (
Livingroom→Living Room), anddiscoveronly adds and updates, it never prunes — so the old entry lingers with an alias the controller no longer serves. Process counts look healthy because mpv is up and retrying. Diff your config's aliases against the controller:curl -sk -b cookies.txt https://HOST/proxy/protect/api/bootstrapand compare each camera'schannels[].rtspAlias. Delete the dead entries, then fix every layout that referenced them (watch for a camera ending up twice after a rename). - Cameras in
cameras:aren't on screen — only cameras placed as tiles in the active layout are streamed, one mpv per tile. Thecameras:list is just an inventory. Check withrtsp-streamer layout ls(it prints each layout's tile count). - Black bars inside every tile — the base grid's cells aren't 16:9, so mpv
letterboxes each feed to fit. On a 16:9 output only a square grid (
NxN) yields 16:9 cells; e.g. a5x4grid on 3840x2160 gives 768x540 (1.42:1) cells. Some arrangements can't avoid this at all — two 16:9 tiles 1080px tall need the full 3840px width, leaving no room for a side column. To fill instead of pad, crop withplayer.extra_args: ["--panscan=1.0"], at the cost of roughly the top and bottom 20% of each camera's field of view. - 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, 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