// 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 } } // preset is a Protect arrangement: a base grid plus one cell placement per // slot, in slot order. Camera is filled in by LayoutFromView. type preset struct { cols, rows int cells []config.Tile } // presets holds the Protect arrangements that are NOT a uniform grid, keyed by // slot count. Protect picks these presets by camera count, so a count that is // absent here is a plain row-major grid and falls through to uniformPreset. var presets = map[int]preset{ // Protect's 8-camera preset: four 2x2 tiles with a right-hand column of // four 1x1 tiles. Numbers are slot order: // 1 1 2 2 3 // 1 1 2 2 4 // 5 5 6 6 7 // 5 5 6 6 8 8: {cols: 5, rows: 4, cells: []config.Tile{ {Col: 0, Row: 0, ColSpan: 2, RowSpan: 2}, {Col: 2, Row: 0, ColSpan: 2, RowSpan: 2}, {Col: 4, Row: 0, ColSpan: 1, RowSpan: 1}, {Col: 4, Row: 1, ColSpan: 1, RowSpan: 1}, {Col: 0, Row: 2, ColSpan: 2, RowSpan: 2}, {Col: 2, Row: 2, ColSpan: 2, RowSpan: 2}, {Col: 4, Row: 2, ColSpan: 1, RowSpan: 1}, {Col: 4, Row: 3, ColSpan: 1, RowSpan: 1}, }}, } // uniformPreset builds a plain row-major grid of 1x1 cells. func uniformPreset(cols, rows int) preset { p := preset{cols: cols, rows: rows} for i := 0; i < cols*rows; i++ { p.cells = append(p.cells, config.Tile{Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1}) } return p } // presetForSlots returns the arrangement Protect uses for n slots. func presetForSlots(n int) preset { if p, ok := presets[n]; ok { return p } cols, rows := GridForSlots(n) return uniformPreset(cols, rows) } // LayoutFromView maps a Protect live view to a layout. Each slot's first camera // (slots may cycle several) is placed into that slot's cell in the arrangement // Protect uses for the slot count — see presets. The returned layout is linked // back to the view (ProtectView) so the daemon can re-sync it. Returns warnings // for unmappable cameras and for any arrangement that leaves the wall with // empty cells, which would otherwise show as blank rectangles on screen. func LayoutFromView(v protect.LiveView, idToName map[string]string) (config.Layout, []string) { p := presetForSlots(len(v.Slots)) var tiles []config.Tile var warns []string for i, slot := range v.Slots { if i >= len(p.cells) { warns = append(warns, fmt.Sprintf("more slots than the %dx%d grid holds; extra dropped", p.cols, p.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 (run `discover`)", i, slot.Cameras[0])) continue } t := p.cells[i] t.Camera = name tiles = append(tiles, t) } if gaps := unfilled(p, tiles); gaps > 0 { warns = append(warns, fmt.Sprintf("%d of %d cells in the %dx%d grid are empty and will show as blank areas", gaps, p.cols*p.rows, p.cols, p.rows)) } return config.Layout{ Name: Sanitize(v.Name), Grid: fmt.Sprintf("%dx%d", p.cols, p.rows), Tiles: tiles, ProtectView: v.Name, }, warns } // unfilled counts grid cells no tile covers. A non-zero result means the wall // has holes: either the preset does not tile its own grid or slots were skipped. func unfilled(p preset, tiles []config.Tile) int { if p.cols <= 0 || p.rows <= 0 { return 0 } covered := make([]bool, p.cols*p.rows) for _, t := range tiles { cs, rs := t.Span() for r := t.Row; r < t.Row+rs; r++ { for c := t.Col; c < t.Col+cs; c++ { if r >= 0 && r < p.rows && c >= 0 && c < p.cols { covered[r*p.cols+c] = true } } } } n := 0 for _, ok := range covered { if !ok { n++ } } return n } // 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 }