adding to git.i0t.app
This commit is contained in:
290
cmd/rtsp-streamer/commands.go
Normal file
290
cmd/rtsp-streamer/commands.go
Normal file
@@ -0,0 +1,290 @@
|
||||
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
|
||||
}
|
||||
58
cmd/rtsp-streamer/main.go
Normal file
58
cmd/rtsp-streamer/main.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Command rtsp-streamer is a full-screen RTSP video wall for UniFi Protect
|
||||
// cameras, driven by mpv under a kiosk Wayland compositor and configured from
|
||||
// the command line or an interactive TUI.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgPath string
|
||||
logLevel string
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "rtsp-streamer",
|
||||
Short: "Full-screen RTSP camera wall for UniFi Protect",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
root.PersistentFlags().StringVar(&cfgPath, "config", config.DefaultPath(), "path to config file")
|
||||
root.PersistentFlags().StringVar(&logLevel, "log", "info", "log level: debug|info|warn|error")
|
||||
|
||||
root.AddCommand(
|
||||
daemonCmd(),
|
||||
discoverCmd(),
|
||||
layoutCmd(),
|
||||
statusCmd(),
|
||||
tuiCmd(),
|
||||
configCmd(),
|
||||
)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newLogger() *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch logLevel {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
Reference in New Issue
Block a user