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:
84
internal/viewmap/viewmap.go
Normal file
84
internal/viewmap/viewmap.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user