291 lines
8.0 KiB
Go
291 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
|
|
"github.com/lwoodard/rtsp-streamer/internal/config"
|
|
"github.com/lwoodard/rtsp-streamer/internal/daemon"
|
|
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
|
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
|
"github.com/lwoodard/rtsp-streamer/internal/tui"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// daemonCmd runs the video wall. This is what systemd (via sway) launches.
|
|
func daemonCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "daemon",
|
|
Short: "Run the video wall (launched by the kiosk session)",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
log := newLogger()
|
|
d, err := daemon.New(cfgPath, log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// SIGHUP triggers a config reload via the control socket path is
|
|
// unnecessary here; the daemon reloads on start and on demand.
|
|
return d.Run(ctx)
|
|
},
|
|
}
|
|
}
|
|
|
|
// discoverCmd queries UniFi Protect and merges cameras into the config.
|
|
func discoverCmd() *cobra.Command {
|
|
var lowRes bool
|
|
var enableRTSP string
|
|
c := &cobra.Command{
|
|
Use: "discover",
|
|
Short: "Discover cameras from UniFi Protect and update the config",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.Controller.Host == "" {
|
|
return fmt.Errorf("controller.host is not set; edit %s or run `rtsp-streamer config init`", cfgPath)
|
|
}
|
|
pw := cfg.Controller.ResolvePassword()
|
|
if pw == "" {
|
|
return fmt.Errorf("no controller password (set controller.password or the env var in controller.password_env)")
|
|
}
|
|
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx := context.Background()
|
|
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
|
|
return err
|
|
}
|
|
cams, err := cl.Cameras(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if enableRTSP != "" {
|
|
enabled, failed := cl.EnableMissing(ctx, cams, enableRTSP)
|
|
for _, name := range enabled {
|
|
fmt.Printf(" + enabled RTSP (%s) on %s\n", enableRTSP, name)
|
|
}
|
|
for name, ferr := range failed {
|
|
fmt.Fprintf(os.Stderr, " ! could not enable RTSP on %s: %v\n", name, ferr)
|
|
}
|
|
}
|
|
|
|
added, updated, skipped := 0, 0, 0
|
|
for _, cam := range cams {
|
|
ch := cam.BestEnabledChannel()
|
|
if lowRes {
|
|
ch = cam.LowestEnabledChannel()
|
|
}
|
|
if ch == nil {
|
|
fmt.Fprintf(os.Stderr, " ! %s: no RTSP-enabled channel (enable RTSP in Protect)\n", cam.Name)
|
|
skipped++
|
|
continue
|
|
}
|
|
url := cl.StreamURL(ch.RTSPAlias)
|
|
if existing := findByID(cfg, cam.ID); existing != nil {
|
|
existing.Name, existing.RTSP = cam.Name, url
|
|
updated++
|
|
} else if existing := cfg.CameraByName(cam.Name); existing != nil {
|
|
existing.ID, existing.RTSP = cam.ID, url
|
|
updated++
|
|
} else {
|
|
cfg.Cameras = append(cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
|
|
added++
|
|
}
|
|
fmt.Printf(" ✓ %-24s %dx%d\n", cam.Name, ch.Width, ch.Height)
|
|
}
|
|
if err := config.Save(cfgPath, cfg); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("\nDiscovered %d cameras (%d added, %d updated, %d skipped). Saved to %s\n",
|
|
len(cams), added, updated, skipped, cfgPath)
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().BoolVar(&lowRes, "low-res", false, "prefer the lowest-resolution substream (good for dense grids)")
|
|
c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "enable RTSP in Protect on this channel for cameras that lack it: high|medium|low")
|
|
return c
|
|
}
|
|
|
|
func findByID(cfg *config.Config, id string) *config.Camera {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
for i := range cfg.Cameras {
|
|
if cfg.Cameras[i].ID == id {
|
|
return &cfg.Cameras[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// layoutCmd groups layout subcommands.
|
|
func layoutCmd() *cobra.Command {
|
|
c := &cobra.Command{Use: "layout", Short: "List or switch layouts"}
|
|
|
|
ls := &cobra.Command{
|
|
Use: "ls",
|
|
Short: "List defined layouts",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
names := make([]string, 0, len(cfg.Layouts))
|
|
for _, l := range cfg.Layouts {
|
|
names = append(names, l.Name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, l := range cfg.Layouts {
|
|
marker := " "
|
|
if l.Name == cfg.ActiveLayout {
|
|
marker = "* "
|
|
}
|
|
fmt.Printf("%s%-16s %-6s %d cameras\n", marker, l.Name, l.Grid, len(l.EffectiveTiles()))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
set := &cobra.Command{
|
|
Use: "set <name>",
|
|
Short: "Switch the active layout (live if the daemon is running)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
name := args[0]
|
|
// Prefer switching the live daemon; it persists the choice too.
|
|
if resp, err := ipc.Send(ipc.Request{Cmd: "set-layout", Name: name}); err == nil {
|
|
if !resp.OK {
|
|
return fmt.Errorf("%s", resp.Error)
|
|
}
|
|
fmt.Printf("Active layout is now %q (applied live)\n", name)
|
|
return nil
|
|
}
|
|
// Daemon not running: just update the config for next start.
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.LayoutByName(name) == nil {
|
|
return fmt.Errorf("layout %q not found", name)
|
|
}
|
|
cfg.ActiveLayout = name
|
|
if err := config.Save(cfgPath, cfg); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Active layout set to %q (daemon not running; will apply on next start)\n", name)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
c.AddCommand(ls, set)
|
|
return c
|
|
}
|
|
|
|
// statusCmd asks the running daemon for slot health.
|
|
func statusCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show the running daemon's slot health",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
resp, err := ipc.Send(ipc.Request{Cmd: "status"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.OK {
|
|
return fmt.Errorf("%s", resp.Error)
|
|
}
|
|
fmt.Printf("Active layout: %s\n\n", resp.ActiveLayout)
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
|
fmt.Fprintln(w, "SLOT\tCAMERA\tPID\tRUNNING\tHEALTHY")
|
|
for _, s := range resp.Slots {
|
|
fmt.Fprintf(w, "%d\t%s\t%d\t%v\t%v\n", s.Slot, s.Camera, s.PID, s.Running, s.Healthy)
|
|
}
|
|
return w.Flush()
|
|
},
|
|
}
|
|
}
|
|
|
|
// tuiCmd launches the interactive configurator.
|
|
func tuiCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "tui",
|
|
Aliases: []string{"config-ui"},
|
|
Short: "Interactive configurator (great over SSH)",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return tui.Run(cfgPath)
|
|
},
|
|
}
|
|
}
|
|
|
|
// configCmd holds config-file utilities.
|
|
func configCmd() *cobra.Command {
|
|
c := &cobra.Command{Use: "config", Short: "Config file helpers"}
|
|
|
|
initCmd := &cobra.Command{
|
|
Use: "init",
|
|
Short: "Write a starter config if none exists",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
if _, err := os.Stat(cfgPath); err == nil {
|
|
return fmt.Errorf("config already exists at %s", cfgPath)
|
|
}
|
|
if err := config.Save(cfgPath, starterConfig()); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Wrote starter config to %s\n", cfgPath)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
pathCmd := &cobra.Command{
|
|
Use: "path",
|
|
Short: "Print the resolved config path",
|
|
Run: func(cmd *cobra.Command, _ []string) { fmt.Println(cfgPath) },
|
|
}
|
|
|
|
editCmd := &cobra.Command{
|
|
Use: "validate",
|
|
Short: "Validate the config file",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
if _, err := config.Load(cfgPath); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("config OK")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
c.AddCommand(initCmd, pathCmd, editCmd)
|
|
return c
|
|
}
|
|
|
|
func starterConfig() *config.Config {
|
|
c := &config.Config{
|
|
Controller: config.Controller{
|
|
Host: "192.168.1.1",
|
|
Username: "viewer",
|
|
PasswordEnv: "RTSP_STREAMER_PASSWORD",
|
|
VerifyTLS: false,
|
|
},
|
|
Layouts: []config.Layout{
|
|
// Empty layouts to fill in with `rtsp-streamer tui`.
|
|
{Name: "quad", Grid: "2x2"},
|
|
{Name: "main-plus", Grid: "4x3"},
|
|
{Name: "single", Grid: "1x1"},
|
|
},
|
|
ActiveLayout: "quad",
|
|
}
|
|
c.Defaults()
|
|
return c
|
|
}
|