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

@@ -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

View File

@@ -43,6 +43,7 @@ func main() {
tuiCmd(),
configCmd(),
versionCmd(),
viewsCmd(),
)
if err := root.Execute(); err != nil {