diff --git a/README.md b/README.md index d9deb74..0a5b8eb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,18 @@ On the display device: Debian / Raspberry Pi OS: `sudo apt install sway mpv foot` Arch: `sudo pacman -S sway mpv foot` -To build: Go 1.23+ (only on your dev machine; the Pi just needs the binary). +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 @@ -55,6 +66,9 @@ 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 diff --git a/cmd/rtsp-streamer/commands.go b/cmd/rtsp-streamer/commands.go index d46d682..1f6c970 100644 --- a/cmd/rtsp-streamer/commands.go +++ b/cmd/rtsp-streamer/commands.go @@ -7,6 +7,7 @@ import ( "os/signal" "runtime" "sort" + "strings" "syscall" "text/tabwriter" @@ -56,7 +57,6 @@ func daemonCmd() *cobra.Command { // discoverCmd queries UniFi Protect and merges cameras into the config. func discoverCmd() *cobra.Command { - var lowRes bool var enableRTSP string c := &cobra.Command{ Use: "discover", @@ -86,39 +86,57 @@ func discoverCmd() *cobra.Command { return err } - if enableRTSP != "" { - enabled, failed := cl.EnableMissing(ctx, cams, enableRTSP) - for _, name := range enabled { - fmt.Printf(" + enabled RTSP (%s) on %s\n", enableRTSP, name) - } - for name, ferr := range failed { - fmt.Fprintf(os.Stderr, " ! could not enable RTSP on %s: %v\n", name, ferr) + // Qualities to ensure RTSP-enabled, e.g. --enable-rtsp=high,low + var wantEnable []string + for _, q := range strings.Split(enableRTSP, ",") { + if q = strings.TrimSpace(strings.ToLower(q)); q != "" { + wantEnable = append(wantEnable, q) } } added, updated, skipped := 0, 0, 0 - for _, cam := range cams { - ch := cam.BestEnabledChannel() - if lowRes { - ch = cam.LowestEnabledChannel() + for i := range cams { + cam := &cams[i] + for _, q := range wantEnable { + target := cam.ChannelByPreference(q) + if target == nil { + continue + } + if !target.RTSPEnabled || target.RTSPAlias == "" { + alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID) + if err != nil { + fmt.Fprintf(os.Stderr, " ! %s: enable %q failed: %v\n", cam.Name, q, err) + continue + } + target.RTSPEnabled, target.RTSPAlias = true, alias + fmt.Printf(" + enabled %s on %s\n", q, cam.Name) + } } - if ch == nil { - fmt.Fprintf(os.Stderr, " ! %s: no RTSP-enabled channel (enable RTSP in Protect)\n", cam.Name) + // Record every enabled channel as a selectable quality. + streams := map[string]string{} + for _, ch := range cam.Channels { + if ch.RTSPEnabled && ch.RTSPAlias != "" { + streams[strings.ToLower(ch.Name)] = cl.StreamURL(ch.RTSPAlias) + } + } + if len(streams) == 0 { + fmt.Fprintf(os.Stderr, " ! %s: no RTSP-enabled channel (try --enable-rtsp=high,low)\n", cam.Name) skipped++ continue } - url := cl.StreamURL(ch.RTSPAlias) - if existing := findByID(cfg, cam.ID); existing != nil { - existing.Name, existing.RTSP = cam.Name, url - updated++ - } else if existing := cfg.CameraByName(cam.Name); existing != nil { - existing.ID, existing.RTSP = cam.ID, url - updated++ - } else { - cfg.Cameras = append(cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url}) - added++ + entry := findByID(cfg, cam.ID) + if entry == nil { + entry = cfg.CameraByName(cam.Name) } - fmt.Printf(" ✓ %-24s %dx%d\n", cam.Name, ch.Width, ch.Height) + if entry == nil { + cfg.Cameras = append(cfg.Cameras, config.Camera{}) + entry = &cfg.Cameras[len(cfg.Cameras)-1] + added++ + } else { + updated++ + } + entry.ID, entry.Name, entry.Streams, entry.RTSP = cam.ID, cam.Name, streams, "" + fmt.Printf(" ✓ %-24s [%s]\n", cam.Name, strings.Join(availQual(streams), ",")) } if err := config.Save(cfgPath, cfg); err != nil { return err @@ -128,11 +146,21 @@ func discoverCmd() *cobra.Command { return nil }, } - c.Flags().BoolVar(&lowRes, "low-res", false, "prefer the lowest-resolution substream (good for dense grids)") - c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "enable RTSP in Protect on this channel for cameras that lack it: high|medium|low") + c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "comma-separated channels to enable in Protect and record, e.g. high,low") return c } +// availQual lists the qualities present in a stream map, high to low. +func availQual(streams map[string]string) []string { + var out []string + for _, q := range config.Qualities { + if streams[q] != "" { + out = append(out, q) + } + } + return out +} + func findByID(cfg *config.Config, id string) *config.Camera { if id == "" { return nil diff --git a/config.example.yaml b/config.example.yaml index a423237..5c2cf17 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,23 +16,32 @@ display: height: 1080 player: - hwdec: auto-safe # v4l2m2m/drm on the Pi 4; "no" to force software + hwdec: v4l2m2m-copy # Pi 4 hardware H.264 decoder. "auto-safe" often + # won't engage it; "no" forces software. profile: low-latency + max_fps: 0 # cap rendered fps (0 = uncapped); trims render load restart_backoff_seconds: 3 - extra_args: [] # e.g. ["--vf=fps=15"] to cap decode load + extra_args: [] # Camera catalog. Normally populated by `rtsp-streamer discover`; shown here -# filled in by hand for illustration. Layouts reference cameras by `name`. +# by hand for illustration. Each camera can carry multiple stream qualities +# (high/medium/low) so a tile can choose which to pull. Layouts reference +# cameras by `name`. (A single `rtsp:` URL instead of `streams:` also works.) cameras: - id: 60a1b2c3d4e5f6 # UniFi Protect camera id (blank for manual entries) name: Front Door - rtsp: rtsps://192.168.1.1:7441/aBcD1234?enableSrtp + streams: + high: rtsps://192.168.1.1:7441/aBcD1234?enableSrtp + low: rtsps://192.168.1.1:7441/aBcD5678?enableSrtp - name: Driveway - rtsp: rtsps://192.168.1.1:7441/eFgH5678?enableSrtp + streams: + high: rtsps://192.168.1.1:7441/eFgH1234?enableSrtp + low: rtsps://192.168.1.1:7441/eFgH5678?enableSrtp - name: Back Yard - rtsp: rtsps://192.168.1.1:7441/iJkL9012?enableSrtp + streams: + low: rtsps://192.168.1.1:7441/iJkL9012?enableSrtp - name: Garage - rtsp: rtsps://192.168.1.1:7441/mNoP3456?enableSrtp + rtsp: rtsps://192.168.1.1:7441/mNoP3456?enableSrtp # legacy single-URL form # Layouts place cameras on a base grid (COLSxROWS). Cameras are "tiles" that # may span multiple cells, so you can mix a big main view with small side @@ -48,14 +57,15 @@ layouts: - {camera: Back Yard, col: 0, row: 1} - {camera: Garage, col: 1, row: 1} - # Security-wall style: one big 3x3 main view + a right column of three. + # Security-wall style: one big 3x3 main view (high quality) + a right column + # of three small tiles on the low substream to keep decode load down. - name: main-plus grid: 4x3 tiles: - - {camera: Front Door, col: 0, row: 0, colspan: 3, rowspan: 3} - - {camera: Driveway, col: 3, row: 0} - - {camera: Back Yard, col: 3, row: 1} - - {camera: Garage, col: 3, row: 2} + - {camera: Front Door, col: 0, row: 0, colspan: 3, rowspan: 3, quality: high} + - {camera: Driveway, col: 3, row: 0, quality: low} + - {camera: Back Yard, col: 3, row: 1, quality: low} + - {camera: Garage, col: 3, row: 2, quality: low} - name: front-focus grid: 1x1 diff --git a/internal/config/config.go b/internal/config/config.go index 3b8b2e6..c81a94f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,6 +80,9 @@ type Player struct { Profile string `yaml:"profile"` // ExtraArgs are appended verbatim to every mpv invocation. ExtraArgs []string `yaml:"extra_args,omitempty"` + // MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims + // render/scale load; the bigger decode lever is using substreams. + MaxFPS int `yaml:"max_fps,omitempty"` // RestartBackoffSeconds is how long to wait before relaunching a stream // that exited or stalled. RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"` @@ -92,12 +95,62 @@ type Camera struct { ID string `yaml:"id,omitempty"` // Name is the human label and the key layouts reference. Must be unique. Name string `yaml:"name"` - // RTSP is the fully-resolved stream URL. - RTSP string `yaml:"rtsp"` + // RTSP is a single fully-resolved stream URL. Kept for backward + // compatibility and manual entries; Streams takes precedence when present. + RTSP string `yaml:"rtsp,omitempty"` + // Streams maps a quality ("high"|"medium"|"low") to its stream URL, so a + // tile can choose per-tile which to pull. Populated by discovery. + Streams map[string]string `yaml:"streams,omitempty"` // Disabled hides the camera from selection without deleting it. Disabled bool `yaml:"disabled,omitempty"` } +// Qualities in preference order, high to low. +var Qualities = []string{"high", "medium", "low"} + +// StreamURL returns the URL for the requested quality, falling back sensibly: +// the exact quality, then any lower quality, then any stream at all, then the +// legacy single RTSP field. +func (c Camera) StreamURL(quality string) string { + if len(c.Streams) > 0 { + if quality != "" { + if u := c.Streams[quality]; u != "" { + return u + } + } + // Fall back down the preference list from the requested quality. + start := 0 + for i, q := range Qualities { + if q == quality { + start = i + break + } + } + for _, q := range Qualities[start:] { + if u := c.Streams[q]; u != "" { + return u + } + } + for _, q := range Qualities { + if u := c.Streams[q]; u != "" { + return u + } + } + } + return c.RTSP +} + +// AvailableQualities lists the qualities this camera actually has, high to low. +func (c Camera) AvailableQualities() []string { + var out []string + for _, q := range Qualities { + if c.Streams[q] != "" { + out = append(out, q) + } + } + return out +} + // MaxTiles caps how many simultaneous streams a layout may show. Decoding // more than this on a Pi 4 is impractical even with substreams. const MaxTiles = 16 @@ -125,6 +178,9 @@ type Tile struct { Row int `yaml:"row"` ColSpan int `yaml:"colspan,omitempty"` // defaults to 1 RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1 + // Quality selects which stream to pull for this tile: "high"|"medium"| + // "low". Empty means the camera's best available (see Camera.StreamURL). + Quality string `yaml:"quality,omitempty"` } // Span returns the tile's spans with zero values normalized to 1. diff --git a/internal/config/tiles_test.go b/internal/config/tiles_test.go index 018d74f..43b6d07 100644 --- a/internal/config/tiles_test.go +++ b/internal/config/tiles_test.go @@ -45,6 +45,28 @@ func TestValidateTiles(t *testing.T) { } } +func TestCameraStreamURL(t *testing.T) { + cam := Camera{Name: "c", Streams: map[string]string{"high": "H", "low": "L"}} + if got := cam.StreamURL("low"); got != "L" { + t.Errorf(`StreamURL("low") = %q, want "L"`, got) + } + if got := cam.StreamURL("high"); got != "H" { + t.Errorf(`StreamURL("high") = %q, want "H"`, got) + } + // Requested quality missing → fall down the preference list, then any. + if got := cam.StreamURL("medium"); got != "L" { + t.Errorf(`StreamURL("medium") = %q, want fallback "L"`, got) + } + if got := cam.StreamURL(""); got != "H" { + t.Errorf(`StreamURL("") = %q, want best "H"`, got) + } + // Legacy single-URL camera. + legacy := Camera{Name: "old", RTSP: "R"} + if got := legacy.StreamURL("low"); got != "R" { + t.Errorf("legacy StreamURL = %q, want RTSP fallback R", got) + } +} + func TestValidateTilesCap(t *testing.T) { var tiles []Tile for i := 0; i < MaxTiles+1; i++ { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 20afde7..64cc814 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -122,13 +122,18 @@ func (d *Daemon) applyLayout(ctx context.Context, name string) error { continue } cam := cfg.CameraByName(tile.Camera) - if cam == nil || cam.Disabled || cam.RTSP == "" { + if cam == nil || cam.Disabled { d.log.Warn("skipping tile: camera unavailable", "slot", slot, "camera", tile.Camera) continue } + url := cam.StreamURL(tile.Quality) + if url == "" { + d.log.Warn("skipping tile: no stream url", "slot", slot, "camera", tile.Camera, "quality", tile.Quality) + continue + } cs, rs := tile.Span() rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs) - p := player.New(slot, cam.Name, cam.RTSP, cfg.Player, d.runDir, d.log) + p := player.New(slot, cam.Name, url, cfg.Player, d.runDir, d.log) players = append(players, p) d.wg.Add(1) diff --git a/internal/player/player.go b/internal/player/player.go index 17a85f7..20526b9 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -81,11 +81,16 @@ func (p *Player) args() []string { "--title=" + p.Title(), "--input-ipc-server=" + p.ipcPath, "--hwdec=" + p.cfg.HWDec, - // Live-stream hygiene: prefer TCP transport, keep buffers small. + // Live-stream hygiene: TCP transport, no caching, and drop frames when + // behind so latency self-corrects instead of accumulating a backlog. "--rtsp-transport=tcp", "--profile=low-latency", "--cache=no", - "--demuxer-lavf-o=stimeout=5000000", + "--framedrop=decoder+vo", + "--demuxer-lavf-o=stimeout=5000000,fflags=+nobuffer,flags=+low_delay", + } + if p.cfg.MaxFPS > 0 { + args = append(args, fmt.Sprintf("--vf=fps=%d", p.cfg.MaxFPS)) } if p.cfg.Profile != "" && p.cfg.Profile != "low-latency" { args = append(args, "--profile="+p.cfg.Profile) diff --git a/internal/tui/gridedit.go b/internal/tui/gridedit.go index 396810b..cb49665 100644 --- a/internal/tui/gridedit.go +++ b/internal/tui/gridedit.go @@ -96,6 +96,8 @@ func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.screen, m.cursor = screenCameraPicker, 0 case "c": m.clearAt() + case "q": // cycle this tile's stream quality (high/low/auto) + m.cycleQuality() case "L": // grow wider (toward the right) m.resizeTile(1, 0) case "H": // shrink narrower (from the right) @@ -149,6 +151,36 @@ func (m *model) clearAt() { } } +// cycleQuality steps the tile under the cursor through auto → its camera's +// available stream qualities (high/low/...). Small tiles → pick low; big tiles +// → high, to keep the Pi's decode load down. +func (m *model) cycleQuality() { + idx := m.tileIndexAt(m.edCol, m.edRow) + if idx < 0 { + m.setStatus("no tile here — assign a camera first", true) + return + } + t := &m.curLayout().Tiles[idx] + opts := []string{""} // "" = auto (best available) + if cam := m.cfg.CameraByName(t.Camera); cam != nil { + opts = append(opts, cam.AvailableQualities()...) + } + cur := 0 + for i, q := range opts { + if q == t.Quality { + cur = i + break + } + } + t.Quality = opts[(cur+1)%len(opts)] + m.dirty = true + label := t.Quality + if label == "" { + label = "auto" + } + m.setStatus("tile quality: "+label, false) +} + // resizeTile grows/shrinks the tile under the cursor by the given span deltas, // keeping it in-bounds and non-overlapping. func (m *model) resizeTile(dCol, dRow int) { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9ef8d9c..a6dfa8b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,6 +7,7 @@ package tui import ( "context" "fmt" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -95,8 +96,8 @@ type savedMsg struct { reloaded bool } -// discoverCmd fetches cameras. When enable is non-empty (e.g. "high"), it also -// turns on RTSP in Protect for any camera that lacks an enabled channel. +// discoverCmd fetches cameras. `enable` is a comma-separated list of qualities +// (e.g. "high,low") to ensure RTSP-enabled in Protect and record per camera. func (m *model) discoverCmd(enable string) tea.Cmd { cfg := m.cfg return func() tea.Msg { @@ -119,10 +120,25 @@ func (m *model) discoverCmd(enable string) tea.Cmd { if err != nil { return discoverMsg{err: err} } + var want []string + for _, q := range strings.Split(enable, ",") { + if q = strings.TrimSpace(strings.ToLower(q)); q != "" { + want = append(want, q) + } + } enabled := 0 - if enable != "" { - names, _ := cl.EnableMissing(ctx, cams, enable) - enabled = len(names) + for i := range cams { + cam := &cams[i] + for _, q := range want { + target := cam.ChannelByPreference(q) + if target == nil || (target.RTSPEnabled && target.RTSPAlias != "") { + continue + } + if alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID); err == nil { + target.RTSPEnabled, target.RTSPAlias = true, alias + enabled++ + } + } } return discoverMsg{cams: cams, enabled: enabled} } @@ -161,28 +177,35 @@ func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) { } cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS) added, updated, skipped := 0, 0, 0 - for _, cam := range msg.cams { - ch := cam.BestEnabledChannel() - if ch == nil { + for i := range msg.cams { + cam := &msg.cams[i] + streams := map[string]string{} + for _, ch := range cam.Channels { + if ch.RTSPEnabled && ch.RTSPAlias != "" { + streams[strings.ToLower(ch.Name)] = cl.StreamURL(ch.RTSPAlias) + } + } + if len(streams) == 0 { skipped++ continue } - url := cl.StreamURL(ch.RTSPAlias) - if ex := m.cameraByID(cam.ID); ex != nil { - ex.Name, ex.RTSP = cam.Name, url - updated++ - } else if ex := m.cfg.CameraByName(cam.Name); ex != nil { - ex.ID, ex.RTSP = cam.ID, url - updated++ - } else { - m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url}) - added++ + entry := m.cameraByID(cam.ID) + if entry == nil { + entry = m.cfg.CameraByName(cam.Name) } + if entry == nil { + m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{}) + entry = &m.cfg.Cameras[len(m.cfg.Cameras)-1] + added++ + } else { + updated++ + } + entry.ID, entry.Name, entry.Streams, entry.RTSP = cam.ID, cam.Name, streams, "" } m.dirty = true enabledNote := "" if msg.enabled > 0 { - enabledNote = fmt.Sprintf(", enabled RTSP on %d", msg.enabled) + enabledNote = fmt.Sprintf(", enabled %d channels", msg.enabled) } m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false) m.screen = screenCameras @@ -229,7 +252,7 @@ var menuItems = []string{ menuLayouts: "Layouts", menuSetActive: "Set active layout", menuDiscover: "Discover from UniFi Protect", - menuDiscoverEnable: "Discover + enable RTSP (high)", + menuDiscoverEnable: "Discover + enable RTSP (hi+lo)", menuSave: "Save", menuQuit: "Quit", } @@ -252,8 +275,8 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setStatus("discovering…", false) return m, m.discoverCmd("") case menuDiscoverEnable: - m.setStatus("discovering and enabling RTSP…", false) - return m, m.discoverCmd("high") + m.setStatus("discovering and enabling RTSP (high+low)…", false) + return m, m.discoverCmd("high,low") case menuSave: return m, m.save() case menuQuit: diff --git a/internal/tui/view.go b/internal/tui/view.go index cea52b5..7ee4ff7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -107,13 +107,27 @@ func (m *model) viewLayoutEdit() string { b.WriteString("\n\n") b.WriteString(strings.Join([]string{ hkey("click", "assign cell") + " " + hkey("drag a tile", "resize") + " (mouse)", - hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"), + hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear") + " " + hkey("q", "quality"), "resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"), "base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"), }, "\n")) return b.String() } +// qualityAbbrev shortens a quality name for the compact tile label. +func qualityAbbrev(q string) string { + switch q { + case "high": + return "hi" + case "medium": + return "med" + case "low": + return "lo" + default: + return q + } +} + // renderGrid draws the base grid with tiles, camera labels, continuation marks // for spanned cells, and a highlighted cursor cell — a live picture of the wall. func (m *model) renderGrid(cols, rows int) string { @@ -156,9 +170,17 @@ func (m *model) renderCell(l config.Layout, c, r, line int) string { plain = padTo(truncate(label, cellW), cellW) case isOrigin && line == 1: cs, rs := t.Span() + info := "" if cs > 1 || rs > 1 { - plain = padTo(fmt.Sprintf("%dx%d", cs, rs), cellW) + info = fmt.Sprintf("%dx%d", cs, rs) } + if t.Quality != "" { + if info != "" { + info += " " + } + info += qualityAbbrev(t.Quality) + } + plain = padTo(info, cellW) dim = true default: // continuation cell of a spanned tile plain = center("·", cellW)