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>
85 lines
2.3 KiB
Go
85 lines
2.3 KiB
Go
// 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
|
|
}
|