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:
38
README.md
38
README.md
@@ -146,6 +146,36 @@ rtsp-streamer layout set quad # switch live (persists the choice)
|
|||||||
rtsp-streamer version # baked-in git commit / build date
|
rtsp-streamer version # baked-in git commit / build date
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## UniFi Protect live views
|
||||||
|
|
||||||
|
Copy a saved Protect "Live View" (its cameras and grid) straight into a layout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rtsp-streamer views ls # list the controller's live views
|
||||||
|
rtsp-streamer views import "All Cameras" # import one as a layout
|
||||||
|
rtsp-streamer views import --all # import every view
|
||||||
|
rtsp-streamer views dump # raw view JSON (for tuning odd layouts)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cameras are matched by Protect id, so run `discover` first. Grid size is
|
||||||
|
inferred from the slot count (asymmetric "1 big + N" presets land as an even
|
||||||
|
grid for now — send me `views dump` output to map exact sizing). An imported
|
||||||
|
layout is **linked** to its view (`protect_view:` in the config).
|
||||||
|
|
||||||
|
### Auto-resync
|
||||||
|
|
||||||
|
Set `view_refresh_seconds` (e.g. `300`) and the daemon re-pulls the active
|
||||||
|
layout's linked view on that interval — edits you make in Protect (add/remove a
|
||||||
|
camera, reorder) show up on the wall automatically. This is the **one** feature
|
||||||
|
that needs controller credentials **at runtime**, so export the password for the
|
||||||
|
daemon (in the kiosk user's `~/.bash_profile`, before the sway launcher):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ~/.bash_profile
|
||||||
|
export RTSP_STREAMER_PASSWORD='…'
|
||||||
|
```
|
||||||
|
Leave `view_refresh_seconds` at 0 (default) and the wall stays credential-free.
|
||||||
|
|
||||||
## Deploy to a Pi (boot straight to the wall)
|
## Deploy to a Pi (boot straight to the wall)
|
||||||
|
|
||||||
First-time setup (run `install.sh` **as the user you'll run the wall as** — pass
|
First-time setup (run `install.sh` **as the user you'll run the wall as** — pass
|
||||||
@@ -255,9 +285,11 @@ pegged, work through:
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- **Import views from the UniFi Protect UI** — read Protect's pre-made
|
- **Exact sizing for asymmetric Protect views** — `views import` reproduces the
|
||||||
multi-camera views/layouts and copy them into rtsp-streamer layouts, so you
|
cameras and an even grid today; mapping Protect's `layout` preset id to
|
||||||
don't rebuild an arrangement you already made in Protect.
|
spanning tiles (1-big-plus-N, etc.) is the next refinement.
|
||||||
|
- Slot cycling — Protect slots can rotate through multiple cameras; we take the
|
||||||
|
first. Honor `cycleMode`/`cycleInterval`.
|
||||||
- Layout rotation / cycling on a timer (the daemon already re-tiles on demand).
|
- Layout rotation / cycling on a timer (the daemon already re-tiles on demand).
|
||||||
- TUI create / delete / rename layouts (today the TUI only *edits* existing
|
- TUI create / delete / rename layouts (today the TUI only *edits* existing
|
||||||
ones; new layouts are added in YAML).
|
ones; new layouts are added in YAML).
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||||
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
||||||
"github.com/lwoodard/rtsp-streamer/internal/tui"
|
"github.com/lwoodard/rtsp-streamer/internal/tui"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/viewmap"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,6 +151,143 @@ func discoverCmd() *cobra.Command {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loginClient loads controller creds and returns a logged-in Protect client.
|
||||||
|
func loginClient(cfg *config.Config) (*protect.Client, context.Context, error) {
|
||||||
|
if cfg.Controller.Host == "" {
|
||||||
|
return nil, nil, fmt.Errorf("controller.host is not set")
|
||||||
|
}
|
||||||
|
pw := cfg.Controller.ResolvePassword()
|
||||||
|
if pw == "" {
|
||||||
|
return nil, nil, fmt.Errorf("no controller password (set controller.password or password_env)")
|
||||||
|
}
|
||||||
|
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return cl, ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewsCmd lists or imports UniFi Protect live views as layouts.
|
||||||
|
func viewsCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{Use: "views", Short: "List or import UniFi Protect live views"}
|
||||||
|
|
||||||
|
ls := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Short: "List the controller's saved live views",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cl, ctx, err := loginClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
views, _, err := cl.LiveViews(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "VIEW\tSLOTS\tLAYOUT")
|
||||||
|
for _, v := range views {
|
||||||
|
fmt.Fprintf(w, "%s\t%d\t%d\n", v.Name, len(v.Slots), v.Layout)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
dump := &cobra.Command{
|
||||||
|
Use: "dump",
|
||||||
|
Short: "Print the raw live-view JSON (for mapping asymmetric layouts)",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cl, ctx, err := loginClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, raw, err := cl.LiveViews(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(raw))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var importAll bool
|
||||||
|
imp := &cobra.Command{
|
||||||
|
Use: "import [view-name]",
|
||||||
|
Short: "Import a live view (or --all) as a layout, cameras and grid",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cl, ctx, err := loginClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
views, _, err := cl.LiveViews(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idToName := map[string]string{}
|
||||||
|
for _, cam := range cfg.Cameras {
|
||||||
|
if cam.ID != "" {
|
||||||
|
idToName[cam.ID] = cam.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imported := 0
|
||||||
|
for _, v := range views {
|
||||||
|
if !importAll && (len(args) == 0 || !strings.EqualFold(v.Name, args[0])) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
layout, warns := viewmap.LayoutFromView(v, idToName)
|
||||||
|
for _, wmsg := range warns {
|
||||||
|
fmt.Fprintf(os.Stderr, " ! %s: %s\n", v.Name, wmsg)
|
||||||
|
}
|
||||||
|
if len(layout.Tiles) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, " ! %s: no mappable cameras (run discover first), skipped\n", v.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
upsertLayout(cfg, layout)
|
||||||
|
fmt.Printf(" ✓ imported %q as layout %q (%s, %d cameras)\n", v.Name, layout.Name, layout.Grid, len(layout.Tiles))
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
if imported == 0 {
|
||||||
|
return fmt.Errorf("no views imported (name not found? use `views ls`, or --all)")
|
||||||
|
}
|
||||||
|
if err := config.Save(cfgPath, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("\nImported %d view(s). Saved to %s\n", imported, cfgPath)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
imp.Flags().BoolVar(&importAll, "all", false, "import every live view")
|
||||||
|
|
||||||
|
c.AddCommand(ls, dump, imp)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertLayout replaces a layout of the same name or appends it.
|
||||||
|
func upsertLayout(cfg *config.Config, l config.Layout) {
|
||||||
|
for i := range cfg.Layouts {
|
||||||
|
if cfg.Layouts[i].Name == l.Name {
|
||||||
|
cfg.Layouts[i] = l
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.Layouts = append(cfg.Layouts, l)
|
||||||
|
}
|
||||||
|
|
||||||
// availQual lists the qualities present in a stream map, high to low.
|
// availQual lists the qualities present in a stream map, high to low.
|
||||||
func availQual(streams map[string]string) []string {
|
func availQual(streams map[string]string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func main() {
|
|||||||
tuiCmd(),
|
tuiCmd(),
|
||||||
configCmd(),
|
configCmd(),
|
||||||
versionCmd(),
|
versionCmd(),
|
||||||
|
viewsCmd(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
|
|||||||
@@ -78,3 +78,9 @@ layouts:
|
|||||||
# slots: [Front Door, Driveway, Back Yard, Garage, "", "", "", "", ""]
|
# slots: [Front Door, Driveway, Back Yard, Garage, "", "", "", "", ""]
|
||||||
|
|
||||||
active_layout: quad
|
active_layout: quad
|
||||||
|
|
||||||
|
# Re-sync the active layout from its linked UniFi Protect live view every N
|
||||||
|
# seconds (0 = off). Layouts created by `views import` carry a `protect_view:`
|
||||||
|
# and are re-synced when this is set. Requires the controller password at
|
||||||
|
# runtime (export RTSP_STREAMER_PASSWORD for the daemon).
|
||||||
|
view_refresh_seconds: 0
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ type Config struct {
|
|||||||
|
|
||||||
// ActiveLayout is the name of the layout the daemon renders.
|
// ActiveLayout is the name of the layout the daemon renders.
|
||||||
ActiveLayout string `yaml:"active_layout"`
|
ActiveLayout string `yaml:"active_layout"`
|
||||||
|
|
||||||
|
// ViewRefreshSeconds, when > 0, makes the daemon periodically re-sync the
|
||||||
|
// active layout from its linked UniFi Protect live view (ProtectView).
|
||||||
|
// Requires controller credentials available to the daemon. 0 = off.
|
||||||
|
ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controller holds UniFi Protect connection details.
|
// Controller holds UniFi Protect connection details.
|
||||||
@@ -169,6 +174,10 @@ type Layout struct {
|
|||||||
// Slots is the legacy one-camera-per-cell model (row-major). Kept for
|
// Slots is the legacy one-camera-per-cell model (row-major). Kept for
|
||||||
// backward compatibility; EffectiveTiles converts it to tiles.
|
// backward compatibility; EffectiveTiles converts it to tiles.
|
||||||
Slots []string `yaml:"slots,omitempty"`
|
Slots []string `yaml:"slots,omitempty"`
|
||||||
|
// ProtectView, when set, is the name of the UniFi Protect live view this
|
||||||
|
// layout mirrors. `views import` sets it; the daemon re-syncs it on a timer
|
||||||
|
// when view_refresh_seconds > 0 and controller creds are available.
|
||||||
|
ProtectView string `yaml:"protect_view,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tile places one camera at a rectangular region of the base grid.
|
// Tile places one camera at a rectangular region of the base grid.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||||
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||||
"github.com/lwoodard/rtsp-streamer/internal/player"
|
"github.com/lwoodard/rtsp-streamer/internal/player"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
||||||
|
"github.com/lwoodard/rtsp-streamer/internal/viewmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Daemon owns the running video wall.
|
// Daemon owns the running video wall.
|
||||||
@@ -58,6 +61,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
go d.serveControl(ctx)
|
go d.serveControl(ctx)
|
||||||
go d.healthLoop(ctx)
|
go d.healthLoop(ctx)
|
||||||
|
go d.viewSyncLoop(ctx)
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
d.log.Info("shutting down")
|
d.log.Info("shutting down")
|
||||||
@@ -248,6 +252,102 @@ func (d *Daemon) healthLoop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewSyncLoop periodically re-syncs the active layout from its linked UniFi
|
||||||
|
// Protect live view, so edits made in Protect (cameras, slot order) show up on
|
||||||
|
// the wall without manual re-import. Off unless view_refresh_seconds > 0 and
|
||||||
|
// controller credentials are available to the daemon process.
|
||||||
|
func (d *Daemon) viewSyncLoop(ctx context.Context) {
|
||||||
|
d.mu.Lock()
|
||||||
|
interval := d.cfg.ViewRefreshSeconds
|
||||||
|
d.mu.Unlock()
|
||||||
|
if interval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
warnedNoCreds := false
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
if err := d.syncActiveView(ctx); err != nil {
|
||||||
|
if err == errNoCreds {
|
||||||
|
if !warnedNoCreds {
|
||||||
|
d.log.Warn("view sync enabled but no controller password available to the daemon; skipping")
|
||||||
|
warnedNoCreds = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.log.Warn("view sync failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errNoCreds = fmt.Errorf("no controller credentials")
|
||||||
|
|
||||||
|
// syncActiveView refetches the linked live view and re-applies the layout if it
|
||||||
|
// changed. No-op when the active layout isn't linked to a Protect view.
|
||||||
|
func (d *Daemon) syncActiveView(ctx context.Context) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
cfg := d.cfg
|
||||||
|
name := d.layout
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
active := cfg.LayoutByName(name)
|
||||||
|
if active == nil || active.ProtectView == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.Controller.Host == "" || cfg.Controller.ResolvePassword() == "" {
|
||||||
|
return errNoCreds
|
||||||
|
}
|
||||||
|
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cl.Login(ctx, cfg.Controller.Username, cfg.Controller.ResolvePassword()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
views, _, err := cl.LiveViews(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var view *protect.LiveView
|
||||||
|
for i := range views {
|
||||||
|
if views[i].Name == active.ProtectView {
|
||||||
|
view = &views[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if view == nil {
|
||||||
|
return fmt.Errorf("linked view %q no longer exists on the controller", active.ProtectView)
|
||||||
|
}
|
||||||
|
idToName := map[string]string{}
|
||||||
|
for _, cam := range cfg.Cameras {
|
||||||
|
if cam.ID != "" {
|
||||||
|
idToName[cam.ID] = cam.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuilt, _ := viewmap.LayoutFromView(*view, idToName)
|
||||||
|
rebuilt.Name = active.Name // keep our layout name stable
|
||||||
|
|
||||||
|
if rebuilt.Grid == active.Grid && reflect.DeepEqual(rebuilt.Tiles, active.Tiles) {
|
||||||
|
return nil // unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
d.log.Info("live view changed, re-syncing layout", "view", active.ProtectView, "layout", name)
|
||||||
|
d.mu.Lock()
|
||||||
|
if l := d.cfg.LayoutByName(name); l != nil {
|
||||||
|
l.Grid, l.Tiles = rebuilt.Grid, rebuilt.Tiles
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
if err := config.Save(d.cfgPath, cfg); err != nil {
|
||||||
|
d.log.Warn("could not persist synced layout", "err", err)
|
||||||
|
}
|
||||||
|
return d.applyLayout(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
// serveControl accepts control-socket connections for status/reload/set-layout.
|
// serveControl accepts control-socket connections for status/reload/set-layout.
|
||||||
func (d *Daemon) serveControl(ctx context.Context) {
|
func (d *Daemon) serveControl(ctx context.Context) {
|
||||||
path := ipc.SocketPath()
|
path := ipc.SocketPath()
|
||||||
|
|||||||
@@ -167,6 +167,53 @@ func (c *Client) StreamURL(alias string) string {
|
|||||||
return fmt.Sprintf("rtsps://%s:%d/%s?enableSrtp", c.host, c.rtspPort, alias)
|
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
|
// BestEnabledChannel returns the highest-resolution channel that has RTSP
|
||||||
// enabled, or nil if none are enabled. Preferring the top channel gives the
|
// 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.
|
// sharpest wall tile; callers can pick a lower one for dense grids.
|
||||||
|
|||||||
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