The TUI is now a TypeScript/opentui app in tui/ rather than Bubble Tea.
opentui is a Zig core with TypeScript bindings and no Go bindings, so this half
of the tool can't live in the Go binary; it compiles with Bun into a sibling
executable (rtsp-streamer-tui) that `rtsp-streamer tui` execs.
Everything that isn't presentation stays in Go, reached over three JSON
commands. The configurator holds no credentials and never writes the config
itself:
config export the config, plus limits like max_tiles
config apply (stdin) merge cameras/layouts/active_layout, validate, save
atomically, reload the daemon
discover --json Protect discovery, writing nothing
Two properties of that split are deliberate:
- The controller password never crosses the bridge. It's json:"-" on the way
out, and apply only merges the three keys the TUI edits, so it can't be
clobbered on the way back in either.
- apply re-reads the file before merging, so an editor left open for an hour can
no longer overwrite a `views import`, a `layout set`, or a hand edit made in
the meantime.
Discovery is previewable as a result: `discover --json` writes nothing, the
merge happens in the TUI, and nothing reaches disk until you save. Only
--enable-rtsp has a side effect, and it's on the controller.
Config structs gain json tags mirroring their yaml ones so the config
round-trips through the bridge under the same key names it has on disk, and
maxGridDim moves to config.MaxGridDim so the CLI and both configurators enforce
one ceiling. The write path is byte-for-byte identical to `layout set`, checked
against a copy of a live config.
Visible change: the grid editor draws real bordered boxes, so a spanning tile is
one box instead of an origin cell plus "·" continuation marks, and the
header-offset arithmetic in mouse.go is gone — the framework hit-tests list
rows. Keybindings, the lipgloss palette and the screen flow are carried over
unchanged; S now saves from anywhere.
The Bubble Tea version stays as `tui --legacy`. It's compiled into the Go binary
and needs no Bun, and on a headless Pi the TUI is the only config UI there is,
so a fallback is worth its weight. The cost of the new one is size: ~120 MB
against ~13 MB, since Bun embeds its runtime and opentui's native library.
Tests: 67 bun tests drive the real (in-memory) opentui renderer, including mouse
click and drag, plus tsc --noEmit. `make test-tui` runs both, and
scripts/preview.ts dumps every screen as text without needing a terminal.
Three bugs found during the port are documented in tui/README.md, since none are
apparent from the code: overlapping cell borders render as ┌ where a lattice
needs ┬; a drag dies after the first resize if the tree is rebuilt, because the
renderer captures the press-target renderable; and a rebuilt box has no computed
layout until the next frame, so its screenX reads 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
542 lines
27 KiB
Markdown
542 lines
27 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.
|
||
|
||
The interactive configurator is a second binary built with
|
||
[Bun](https://bun.sh) (it's written in TypeScript — see
|
||
[the TUI section](#the-tui)). It is **optional**: `make` builds only the Go
|
||
binary, and everything except `rtsp-streamer tui` works without it. Install Bun
|
||
only if you want the configurator:
|
||
|
||
```sh
|
||
curl -fsSL https://bun.sh/install | bash # then: make tui
|
||
```
|
||
|
||
Neither Bun nor Node is needed at *run* time — `make tui` produces a standalone
|
||
executable. And `rtsp-streamer tui --legacy` is built into the Go binary, so a
|
||
box without Bun still has a configurator.
|
||
|
||
> ⚠️ 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
|
||
```
|
||
|
||
The configurator is built separately (needs Bun; skip it and use
|
||
`rtsp-streamer tui --legacy`):
|
||
|
||
```sh
|
||
make tui # host arch -> bin/rtsp-streamer-tui (standalone, ~120 MB)
|
||
make tui-pi # arm64 -> bin/rtsp-streamer-tui-arm64 (the `make pi` analogue)
|
||
make test-tui # bun test + tsc --noEmit
|
||
make run-tui # run it from source against /tmp/rtsp-streamer.yaml
|
||
```
|
||
|
||
`make install` copies `bin/rtsp-streamer-tui` alongside the main binary when it
|
||
exists, which is where `rtsp-streamer tui` looks for it. If you cross-compiled
|
||
with `make tui-pi`, rename the `-arm64` artifact to `rtsp-streamer-tui` on the
|
||
target, or point `$RTSP_STREAMER_TUI` at it.
|
||
|
||
## 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 an [opentui](https://opentui.com) configurator meant to be
|
||
run over SSH:
|
||
|
||
- **Cameras** — review discovered cameras, `d` disable, `x` delete.
|
||
- **Layouts** — edit an arrangement on a live grid preview.
|
||
- **Set active layout** — choose what the wall shows.
|
||
- **Discover** / **Discover + enable RTSP (hi+lo)** — pull cameras from Protect.
|
||
- **Save** — writes the config and tells a running daemon to reload live.
|
||
|
||
It is a separate executable (`rtsp-streamer-tui`) built by Bun, because opentui is
|
||
a TypeScript library — see [`tui/README.md`](tui/README.md) for the architecture
|
||
and the Go↔TS bridge. Build it with `make tui`; `make install` puts it alongside
|
||
the main binary. The previous Bubble Tea implementation is still available as
|
||
`rtsp-streamer tui --legacy`, which needs no Bun runtime.
|
||
|
||
In the **layout editor** the grid is drawn live as you edit, with a spanning tile
|
||
shown as a single box:
|
||
|
||
```
|
||
┌─Driveway─────────────────────────────────────┐┌─Front Door───┐
|
||
│ 3x2 hi ││ lo │
|
||
│ ││ │
|
||
│ │└──────────────┘
|
||
│ │┌──────────────┐
|
||
│ ││ │
|
||
└──────────────────────────────────────────────┘└──────────────┘
|
||
┌──────────────┐┌──────────────┐┌──────────────┐┌─Back Yard────┐
|
||
│ ││ ││ ││ auto │
|
||
└──────────────┘└──────────────┘└──────────────┘└──────────────┘
|
||
```
|
||
|
||
- **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.
|
||
`S` saves from any screen.
|
||
|
||
### 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.
|
||
|
||
### JSON interface
|
||
|
||
Three commands speak JSON. The configurator in `tui/` is built on them, and
|
||
they're the supported way to script config changes:
|
||
|
||
```sh
|
||
rtsp-streamer config export # whole config as JSON on stdout
|
||
rtsp-streamer config apply < edits.json # merge, validate, save, reload
|
||
rtsp-streamer discover --json # Protect discovery, writes nothing
|
||
rtsp-streamer discover --json --enable-rtsp=high,low
|
||
```
|
||
|
||
`config apply` reads an object with any of `cameras`, `layouts` and
|
||
`active_layout`, and **merges** it into a fresh read of the file — absent keys are
|
||
left alone, and the `controller` section is never touched. So a one-key edit needs
|
||
nothing else in the payload:
|
||
|
||
```sh
|
||
echo '{"active_layout": "quad"}' | rtsp-streamer config apply
|
||
```
|
||
|
||
(That is exactly what `layout set quad` does, and it leaves cameras and layouts
|
||
untouched. `--json` output pairs well with `jq` if you have it.)
|
||
|
||
Two deliberate properties:
|
||
|
||
- **The controller password never appears in `config export`,** and `apply` can't
|
||
overwrite it. Secrets stay in the file (or `$RTSP_STREAMER_PASSWORD`).
|
||
- **`apply` re-reads before merging,** so a long-running editor can't clobber a
|
||
`views import`, a `layout set`, or a hand edit made in the meantime.
|
||
|
||
`discover --json` writes nothing, which makes discovery previewable — only
|
||
`--enable-rtsp` has a side effect, and it is on the *controller* (it switches
|
||
RTSP on for those channels in Protect).
|
||
|
||
Every one of the three prints a JSON document on stdout even when it fails, with
|
||
an `ok` field, and exits non-zero on failure.
|
||
|
||
## Clock overlay
|
||
|
||
An optional always-on-top clock can be rendered at a screen edge:
|
||
|
||
```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-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_opacity` must be greater than zero.** A fully transparent canvas
|
||
(`color=c=black@0.0` with `--alpha=yes`) makes the drawn glyphs inherit alpha
|
||
0, and the compositor shows nothing at all. Values of `0` or below — including
|
||
the field being absent — are clamped to the default.
|
||
- **The canvas must not be buffered ahead** (`--cache=no`,
|
||
`--demuxer-readahead-secs=0`). `drawtext` stamps 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:
|
||
|
||
```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. 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):
|
||
|
||
```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` covers the daemon and the CLI. It does **not** rebuild the
|
||
configurator — that is a separate, slower build, and it only matters when
|
||
something under `tui/` changed:
|
||
|
||
```sh
|
||
git pull && make tui && 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 (hi+lo)"** 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 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`), and `discover` only 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/bootstrap` and compare
|
||
each camera's `channels[].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. The `cameras:` list is just an
|
||
inventory. Check with `rtsp-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. a `5x4` grid 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 with `player.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=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).
|
||
|
||
- **`rtsp-streamer tui` says `rtsp-streamer-tui not found`** — the configurator
|
||
binary isn't built or isn't installed. Either build it (`make tui && make
|
||
install`, needs Bun) or use `rtsp-streamer tui --legacy`, which is compiled into
|
||
the Go binary and always available. `$RTSP_STREAMER_TUI` overrides the path.
|
||
|
||
- **`rtsp-streamer tui` exits immediately with a config error** — the configurator
|
||
loads the config through `rtsp-streamer config export` *before* taking over the
|
||
terminal, so an invalid config surfaces as a plain error rather than a broken
|
||
screen. Run `rtsp-streamer config validate` to see the same problem.
|
||
|
||
- **The configurator can't reach the Go binary** — it shells back to
|
||
`rtsp-streamer` for every config and Protect operation, resolved from
|
||
`$RTSP_STREAMER_BIN` (set automatically by `rtsp-streamer tui`) and otherwise
|
||
from `$PATH`. Running the compiled TUI directly from a shell where
|
||
`rtsp-streamer` isn't on `PATH` is the usual cause.
|
||
|
||
## 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/ legacy Bubble Tea configurator (`tui --legacy`)
|
||
tui/ opentui configurator (TypeScript/Bun) — see tui/README.md
|
||
deploy/ sway kiosk config, autologin, install.sh
|
||
```
|