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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user