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
437 lines
22 KiB
Markdown
437 lines
22 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 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` 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 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).
|
||
|
||
## 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
|
||
```
|