A small always-on-top mpv window renders the local time in a screen corner. Implementation notes: - Transparent lavfi canvas (color=...@0.0, --alpha=yes) so the camera video shows through; no new dependencies. If the compositor can't do alpha it degrades to a dark backing, still legible. - Time drawn as an ASS osd-overlay pushed over mpv IPC once a second: white fill + black outline (\bord), so it reads on both bright (day) and dark (night) scenes without sampling the picture. Formatting the text in Go avoids any filtergraph escaping. - Time computed with time.LoadLocation against a configured IANA zone (default "Local"), so it's correct regardless of the host clock's zone and handles DST. A bad zone name fails at startup. - Managed on the daemon's own context (survives layout switches); the daemon keeps it positioned and raised above camera tiles. ensureClock is a no-op when the clock config + resolution are unchanged, so a reload never disturbs it. Config: new `clock` section (enabled, timezone, format, corner, font_size, width, height, margin) with defaults and corner validation. Documented in README and config.example (shipped enabled, America/Denver, 24-hour w/ seconds). Tests cover corner geometry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
369 lines
18 KiB
Markdown
369 lines
18 KiB
Markdown
# 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.
|
||
|
||
## Clock overlay
|
||
|
||
An optional always-on-top clock can be rendered in a screen corner:
|
||
|
||
```yaml
|
||
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-right # bottom-right | bottom-left | top-right | top-left
|
||
```
|
||
|
||
It's a tiny transparent mpv window (no extra dependencies) drawing the time as
|
||
**white text with a black outline**, 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`.
|
||
|
||
## 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.
|
||
- **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 / 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
|
||
```
|