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

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