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

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