Import UniFi Protect live views + optional auto-resync

views ls/dump/import copy a Protect live view (cameras + grid) into a
layout; grid inferred from slot count (asymmetric presets TBD, see
views dump). Imported layouts link back via protect_view.

Daemon re-syncs the active linked view every view_refresh_seconds (0=
off), so Protect-side edits appear on the wall. Needs the controller
password at runtime; off by default keeps the wall credential-free.

Shared viewmap package used by CLI and daemon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Woodard
2026-07-02 07:13:51 -05:00
parent 9da5ace150
commit 291d37ec83
8 changed files with 420 additions and 3 deletions

View File

@@ -36,6 +36,11 @@ type Config struct {
// ActiveLayout is the name of the layout the daemon renders.
ActiveLayout string `yaml:"active_layout"`
// ViewRefreshSeconds, when > 0, makes the daemon periodically re-sync the
// active layout from its linked UniFi Protect live view (ProtectView).
// Requires controller credentials available to the daemon. 0 = off.
ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty"`
}
// Controller holds UniFi Protect connection details.
@@ -169,6 +174,10 @@ type Layout struct {
// Slots is the legacy one-camera-per-cell model (row-major). Kept for
// backward compatibility; EffectiveTiles converts it to tiles.
Slots []string `yaml:"slots,omitempty"`
// ProtectView, when set, is the name of the UniFi Protect live view this
// layout mirrors. `views import` sets it; the daemon re-syncs it on a timer
// when view_refresh_seconds > 0 and controller creds are available.
ProtectView string `yaml:"protect_view,omitempty"`
}
// Tile places one camera at a rectangular region of the base grid.

View File

@@ -10,6 +10,7 @@ import (
"log/slog"
"net"
"os"
"reflect"
"sync"
"time"
@@ -17,6 +18,8 @@ import (
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/ipc"
"github.com/lwoodard/rtsp-streamer/internal/player"
"github.com/lwoodard/rtsp-streamer/internal/protect"
"github.com/lwoodard/rtsp-streamer/internal/viewmap"
)
// Daemon owns the running video wall.
@@ -58,6 +61,7 @@ func (d *Daemon) Run(ctx context.Context) error {
go d.serveControl(ctx)
go d.healthLoop(ctx)
go d.viewSyncLoop(ctx)
<-ctx.Done()
d.log.Info("shutting down")
@@ -248,6 +252,102 @@ func (d *Daemon) healthLoop(ctx context.Context) {
}
}
// viewSyncLoop periodically re-syncs the active layout from its linked UniFi
// Protect live view, so edits made in Protect (cameras, slot order) show up on
// the wall without manual re-import. Off unless view_refresh_seconds > 0 and
// controller credentials are available to the daemon process.
func (d *Daemon) viewSyncLoop(ctx context.Context) {
d.mu.Lock()
interval := d.cfg.ViewRefreshSeconds
d.mu.Unlock()
if interval <= 0 {
return
}
ticker := time.NewTicker(time.Duration(interval) * time.Second)
defer ticker.Stop()
warnedNoCreds := false
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if err := d.syncActiveView(ctx); err != nil {
if err == errNoCreds {
if !warnedNoCreds {
d.log.Warn("view sync enabled but no controller password available to the daemon; skipping")
warnedNoCreds = true
}
continue
}
d.log.Warn("view sync failed", "err", err)
}
}
}
var errNoCreds = fmt.Errorf("no controller credentials")
// syncActiveView refetches the linked live view and re-applies the layout if it
// changed. No-op when the active layout isn't linked to a Protect view.
func (d *Daemon) syncActiveView(ctx context.Context) error {
d.mu.Lock()
cfg := d.cfg
name := d.layout
d.mu.Unlock()
active := cfg.LayoutByName(name)
if active == nil || active.ProtectView == "" {
return nil
}
if cfg.Controller.Host == "" || cfg.Controller.ResolvePassword() == "" {
return errNoCreds
}
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
if err != nil {
return err
}
if err := cl.Login(ctx, cfg.Controller.Username, cfg.Controller.ResolvePassword()); err != nil {
return err
}
views, _, err := cl.LiveViews(ctx)
if err != nil {
return err
}
var view *protect.LiveView
for i := range views {
if views[i].Name == active.ProtectView {
view = &views[i]
break
}
}
if view == nil {
return fmt.Errorf("linked view %q no longer exists on the controller", active.ProtectView)
}
idToName := map[string]string{}
for _, cam := range cfg.Cameras {
if cam.ID != "" {
idToName[cam.ID] = cam.Name
}
}
rebuilt, _ := viewmap.LayoutFromView(*view, idToName)
rebuilt.Name = active.Name // keep our layout name stable
if rebuilt.Grid == active.Grid && reflect.DeepEqual(rebuilt.Tiles, active.Tiles) {
return nil // unchanged
}
d.log.Info("live view changed, re-syncing layout", "view", active.ProtectView, "layout", name)
d.mu.Lock()
if l := d.cfg.LayoutByName(name); l != nil {
l.Grid, l.Tiles = rebuilt.Grid, rebuilt.Tiles
}
d.mu.Unlock()
if err := config.Save(d.cfgPath, cfg); err != nil {
d.log.Warn("could not persist synced layout", "err", err)
}
return d.applyLayout(ctx, name)
}
// serveControl accepts control-socket connections for status/reload/set-layout.
func (d *Daemon) serveControl(ctx context.Context) {
path := ipc.SocketPath()

View File

@@ -167,6 +167,53 @@ func (c *Client) StreamURL(alias string) string {
return fmt.Sprintf("rtsps://%s:%d/%s?enableSrtp", c.host, c.rtspPort, alias)
}
// LiveView is a saved Protect "Live View": an ordered list of slots plus a
// layout preset id. Each slot holds one or more cameras (cyclable).
type LiveView struct {
ID string `json:"id"`
Name string `json:"name"`
Layout int `json:"layout"` // Protect grid-preset id
Slots []LiveViewSlot `json:"slots"`
}
// LiveViewSlot is one cell of a live view.
type LiveViewSlot struct {
Cameras []string `json:"cameras"` // Protect camera ids
CycleMode string `json:"cycleMode"`
CycleInterval int `json:"cycleInterval"`
}
// LiveViews returns the controller's saved live views (from the bootstrap
// document, which always includes them) plus the raw liveviews JSON, pretty-
// printed, so callers can inspect the exact `layout`/`slots` shape.
func (c *Client) LiveViews(ctx context.Context) ([]LiveView, []byte, error) {
resp, err := c.do(ctx, http.MethodGet, "/proxy/protect/api/bootstrap", nil)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, nil, fmt.Errorf("bootstrap failed (%s): %s", resp.Status, strings.TrimSpace(string(snippet)))
}
var b struct {
LiveViews []json.RawMessage `json:"liveviews"`
}
if err := json.NewDecoder(resp.Body).Decode(&b); err != nil {
return nil, nil, fmt.Errorf("decoding bootstrap: %w", err)
}
views := make([]LiveView, 0, len(b.LiveViews))
for _, raw := range b.LiveViews {
var v LiveView
if err := json.Unmarshal(raw, &v); err != nil {
continue
}
views = append(views, v)
}
pretty, _ := json.MarshalIndent(b.LiveViews, "", " ")
return views, pretty, nil
}
// BestEnabledChannel returns the highest-resolution channel that has RTSP
// enabled, or nil if none are enabled. Preferring the top channel gives the
// sharpest wall tile; callers can pick a lower one for dense grids.

View File

@@ -0,0 +1,84 @@
// Package viewmap converts a UniFi Protect live view into an rtsp-streamer
// layout. It is shared by the `views import` CLI command and the daemon's
// periodic re-sync so both produce identical layouts.
package viewmap
import (
"fmt"
"strings"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/protect"
)
// GridForSlots picks a near-square grid that fits n slots (capped at 16).
func GridForSlots(n int) (cols, rows int) {
switch {
case n <= 1:
return 1, 1
case n <= 2:
return 2, 1
case n <= 4:
return 2, 2
case n <= 6:
return 3, 2
case n <= 9:
return 3, 3
case n <= 12:
return 4, 3
default:
return 4, 4
}
}
// LayoutFromView maps a Protect live view to a layout. Cameras are placed
// row-major, one per slot (the first camera of a cycling slot). Sizing uses a
// slot-count grid for now; asymmetric Protect presets need the `layout` int
// mapping. The returned layout is linked back to the view (ProtectView) so the
// daemon can re-sync it. Returns warnings for unmappable cameras.
func LayoutFromView(v protect.LiveView, idToName map[string]string) (config.Layout, []string) {
cols, rows := GridForSlots(len(v.Slots))
var tiles []config.Tile
var warns []string
for i, slot := range v.Slots {
if i >= cols*rows {
warns = append(warns, fmt.Sprintf("more slots than the %dx%d grid holds; extra dropped", cols, rows))
break
}
if len(slot.Cameras) == 0 {
continue
}
name := idToName[slot.Cameras[0]]
if name == "" {
warns = append(warns, fmt.Sprintf("slot %d camera %s not in config", i, slot.Cameras[0]))
continue
}
tiles = append(tiles, config.Tile{Camera: name, Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1})
}
return config.Layout{
Name: Sanitize(v.Name),
Grid: fmt.Sprintf("%dx%d", cols, rows),
Tiles: tiles,
ProtectView: v.Name,
}, warns
}
// Sanitize turns a view name into a layout name (lowercase, dashed).
func Sanitize(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
default:
return '-'
}
}, s)
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
if s = strings.Trim(s, "-"); s == "" {
return "imported"
}
return s
}