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:
@@ -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
|
||||
|
||||
@@ -43,6 +43,7 @@ func main() {
|
||||
tuiCmd(),
|
||||
configCmd(),
|
||||
versionCmd(),
|
||||
viewsCmd(),
|
||||
)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user