adding to git.i0t.app

This commit is contained in:
Levi Woodard
2026-07-01 18:21:14 -05:00
commit 55a8ea4bee
26 changed files with 3534 additions and 0 deletions

View 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
View 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}))
}