From 291d37ec838bd2ec4d43b37d819e0a1a00f2accc Mon Sep 17 00:00:00 2001 From: Levi Woodard Date: Thu, 2 Jul 2026 07:13:51 -0500 Subject: [PATCH] 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) --- README.md | 38 +++++++++- cmd/rtsp-streamer/commands.go | 138 ++++++++++++++++++++++++++++++++++ cmd/rtsp-streamer/main.go | 1 + config.example.yaml | 6 ++ internal/config/config.go | 9 +++ internal/daemon/daemon.go | 100 ++++++++++++++++++++++++ internal/protect/protect.go | 47 ++++++++++++ internal/viewmap/viewmap.go | 84 +++++++++++++++++++++ 8 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 internal/viewmap/viewmap.go diff --git a/README.md b/README.md index 3fe316f..4ca00c1 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,36 @@ rtsp-streamer layout set quad # switch live (persists the choice) rtsp-streamer version # baked-in git commit / build date ``` +## 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 @@ -255,9 +285,11 @@ pegged, work through: ## Roadmap -- **Import views from the UniFi Protect UI** — read Protect's pre-made - multi-camera views/layouts and copy them into rtsp-streamer layouts, so you - don't rebuild an arrangement you already made in Protect. +- **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). diff --git a/cmd/rtsp-streamer/commands.go b/cmd/rtsp-streamer/commands.go index 1f6c970..1c22911 100644 --- a/cmd/rtsp-streamer/commands.go +++ b/cmd/rtsp-streamer/commands.go @@ -16,6 +16,7 @@ import ( "github.com/lwoodard/rtsp-streamer/internal/ipc" "github.com/lwoodard/rtsp-streamer/internal/protect" "github.com/lwoodard/rtsp-streamer/internal/tui" + "github.com/lwoodard/rtsp-streamer/internal/viewmap" "github.com/spf13/cobra" ) @@ -150,6 +151,143 @@ func discoverCmd() *cobra.Command { return c } +// loginClient loads controller creds and returns a logged-in Protect client. +func loginClient(cfg *config.Config) (*protect.Client, context.Context, error) { + if cfg.Controller.Host == "" { + return nil, nil, fmt.Errorf("controller.host is not set") + } + pw := cfg.Controller.ResolvePassword() + if pw == "" { + return nil, nil, fmt.Errorf("no controller password (set controller.password or password_env)") + } + cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS) + if err != nil { + return nil, nil, err + } + ctx := context.Background() + if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil { + return nil, nil, err + } + return cl, ctx, nil +} + +// viewsCmd lists or imports UniFi Protect live views as layouts. +func viewsCmd() *cobra.Command { + c := &cobra.Command{Use: "views", Short: "List or import UniFi Protect live views"} + + ls := &cobra.Command{ + Use: "ls", + Short: "List the controller's saved live views", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + cl, ctx, err := loginClient(cfg) + if err != nil { + return err + } + views, _, err := cl.LiveViews(ctx) + if err != nil { + return err + } + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "VIEW\tSLOTS\tLAYOUT") + for _, v := range views { + fmt.Fprintf(w, "%s\t%d\t%d\n", v.Name, len(v.Slots), v.Layout) + } + return w.Flush() + }, + } + + dump := &cobra.Command{ + Use: "dump", + Short: "Print the raw live-view JSON (for mapping asymmetric layouts)", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + cl, ctx, err := loginClient(cfg) + if err != nil { + return err + } + _, raw, err := cl.LiveViews(ctx) + if err != nil { + return err + } + fmt.Println(string(raw)) + return nil + }, + } + + var importAll bool + imp := &cobra.Command{ + Use: "import [view-name]", + Short: "Import a live view (or --all) as a layout, cameras and grid", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + cl, ctx, err := loginClient(cfg) + if err != nil { + return err + } + views, _, err := cl.LiveViews(ctx) + if err != nil { + return err + } + idToName := map[string]string{} + for _, cam := range cfg.Cameras { + if cam.ID != "" { + idToName[cam.ID] = cam.Name + } + } + imported := 0 + for _, v := range views { + if !importAll && (len(args) == 0 || !strings.EqualFold(v.Name, args[0])) { + continue + } + layout, warns := viewmap.LayoutFromView(v, idToName) + for _, wmsg := range warns { + fmt.Fprintf(os.Stderr, " ! %s: %s\n", v.Name, wmsg) + } + if len(layout.Tiles) == 0 { + fmt.Fprintf(os.Stderr, " ! %s: no mappable cameras (run discover first), skipped\n", v.Name) + continue + } + upsertLayout(cfg, layout) + fmt.Printf(" ✓ imported %q as layout %q (%s, %d cameras)\n", v.Name, layout.Name, layout.Grid, len(layout.Tiles)) + imported++ + } + if imported == 0 { + return fmt.Errorf("no views imported (name not found? use `views ls`, or --all)") + } + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + fmt.Printf("\nImported %d view(s). Saved to %s\n", imported, cfgPath) + return nil + }, + } + imp.Flags().BoolVar(&importAll, "all", false, "import every live view") + + c.AddCommand(ls, dump, imp) + return c +} + +// upsertLayout replaces a layout of the same name or appends it. +func upsertLayout(cfg *config.Config, l config.Layout) { + for i := range cfg.Layouts { + if cfg.Layouts[i].Name == l.Name { + cfg.Layouts[i] = l + return + } + } + cfg.Layouts = append(cfg.Layouts, l) +} + // availQual lists the qualities present in a stream map, high to low. func availQual(streams map[string]string) []string { var out []string diff --git a/cmd/rtsp-streamer/main.go b/cmd/rtsp-streamer/main.go index 70a5eba..011be0d 100644 --- a/cmd/rtsp-streamer/main.go +++ b/cmd/rtsp-streamer/main.go @@ -43,6 +43,7 @@ func main() { tuiCmd(), configCmd(), versionCmd(), + viewsCmd(), ) if err := root.Execute(); err != nil { diff --git a/config.example.yaml b/config.example.yaml index 5c2cf17..a19498c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -78,3 +78,9 @@ layouts: # slots: [Front Door, Driveway, Back Yard, Garage, "", "", "", "", ""] active_layout: quad + +# Re-sync the active layout from its linked UniFi Protect live view every N +# seconds (0 = off). Layouts created by `views import` carry a `protect_view:` +# and are re-synced when this is set. Requires the controller password at +# runtime (export RTSP_STREAMER_PASSWORD for the daemon). +view_refresh_seconds: 0 diff --git a/internal/config/config.go b/internal/config/config.go index c81a94f..860a339 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 64cc814..133fea1 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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() diff --git a/internal/protect/protect.go b/internal/protect/protect.go index e101fa0..668cc20 100644 --- a/internal/protect/protect.go +++ b/internal/protect/protect.go @@ -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. diff --git a/internal/viewmap/viewmap.go b/internal/viewmap/viewmap.go new file mode 100644 index 0000000..0997ff7 --- /dev/null +++ b/internal/viewmap/viewmap.go @@ -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 +}