adding to git.i0t.app
This commit is contained in:
409
internal/tui/tui.go
Normal file
409
internal/tui/tui.go
Normal file
@@ -0,0 +1,409 @@
|
||||
// Package tui is the interactive Bubble Tea configurator. It is meant to be
|
||||
// run over SSH on the headless Pi: browse cameras, assign them to layout
|
||||
// slots, pick the active layout, discover cameras from UniFi Protect, and save
|
||||
// — signalling the running daemon to reload on save.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenMenu screen = iota
|
||||
screenCameras
|
||||
screenLayouts
|
||||
screenLayoutEdit
|
||||
screenCameraPicker
|
||||
screenSetActive
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Padding(0, 1)
|
||||
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
// helpStyle adapts to light/dark terminals so it stays legible on both.
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "238", Dark: "251"}).MarginTop(1)
|
||||
// keyStyle accents the key names inside help lines.
|
||||
keyStyle = lipgloss.NewStyle().Bold(true).
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "26", Dark: "81"})
|
||||
// usedStyle marks cameras already placed in the current layout.
|
||||
usedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||
// cursorCellStyle highlights the selected grid cell in the layout editor.
|
||||
cursorCellStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("205"))
|
||||
)
|
||||
|
||||
// hkey formats "<key> <desc>" with the key accented, for help lines.
|
||||
func hkey(key, desc string) string {
|
||||
return keyStyle.Render(key) + " " + desc
|
||||
}
|
||||
|
||||
type model struct {
|
||||
cfgPath string
|
||||
cfg *config.Config
|
||||
dirty bool
|
||||
|
||||
screen screen
|
||||
cursor int
|
||||
|
||||
editLayout int // index into cfg.Layouts for edit/picker screens
|
||||
edCol, edRow int // grid-editor cursor position
|
||||
|
||||
status string
|
||||
isError bool
|
||||
}
|
||||
|
||||
// Run loads the config and starts the TUI event loop.
|
||||
func Run(cfgPath string) error {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu}
|
||||
_, err = tea.NewProgram(m, tea.WithAltScreen()).Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd { return nil }
|
||||
|
||||
// ---- messages ----
|
||||
|
||||
type discoverMsg struct {
|
||||
cams []protect.Camera
|
||||
enabled int // count of cameras we turned RTSP on for
|
||||
err error
|
||||
}
|
||||
|
||||
type savedMsg struct {
|
||||
err error
|
||||
reloaded bool
|
||||
}
|
||||
|
||||
// discoverCmd fetches cameras. When enable is non-empty (e.g. "high"), it also
|
||||
// turns on RTSP in Protect for any camera that lacks an enabled channel.
|
||||
func (m *model) discoverCmd(enable string) tea.Cmd {
|
||||
cfg := m.cfg
|
||||
return func() tea.Msg {
|
||||
if cfg.Controller.Host == "" {
|
||||
return discoverMsg{err: fmt.Errorf("controller.host is not set")}
|
||||
}
|
||||
pw := cfg.Controller.ResolvePassword()
|
||||
if pw == "" {
|
||||
return discoverMsg{err: fmt.Errorf("no controller password available")}
|
||||
}
|
||||
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
||||
if err != nil {
|
||||
return discoverMsg{err: err}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
|
||||
return discoverMsg{err: err}
|
||||
}
|
||||
cams, err := cl.Cameras(ctx)
|
||||
if err != nil {
|
||||
return discoverMsg{err: err}
|
||||
}
|
||||
enabled := 0
|
||||
if enable != "" {
|
||||
names, _ := cl.EnableMissing(ctx, cams, enable)
|
||||
enabled = len(names)
|
||||
}
|
||||
return discoverMsg{cams: cams, enabled: enabled}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- update ----
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case discoverMsg:
|
||||
return m.handleDiscover(msg)
|
||||
case savedMsg:
|
||||
if msg.err != nil {
|
||||
m.setStatus("save failed: "+msg.err.Error(), true)
|
||||
return m, nil
|
||||
}
|
||||
m.dirty = false
|
||||
if msg.reloaded {
|
||||
m.setStatus("saved and reloaded the running daemon", false)
|
||||
} else {
|
||||
m.setStatus("saved to "+m.cfgPath, false)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.err != nil {
|
||||
m.setStatus("discover failed: "+msg.err.Error(), true)
|
||||
return m, nil
|
||||
}
|
||||
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
|
||||
added, updated, skipped := 0, 0, 0
|
||||
for _, cam := range msg.cams {
|
||||
ch := cam.BestEnabledChannel()
|
||||
if ch == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
url := cl.StreamURL(ch.RTSPAlias)
|
||||
if ex := m.cameraByID(cam.ID); ex != nil {
|
||||
ex.Name, ex.RTSP = cam.Name, url
|
||||
updated++
|
||||
} else if ex := m.cfg.CameraByName(cam.Name); ex != nil {
|
||||
ex.ID, ex.RTSP = cam.ID, url
|
||||
updated++
|
||||
} else {
|
||||
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
|
||||
added++
|
||||
}
|
||||
}
|
||||
m.dirty = true
|
||||
enabledNote := ""
|
||||
if msg.enabled > 0 {
|
||||
enabledNote = fmt.Sprintf(", enabled RTSP on %d", msg.enabled)
|
||||
}
|
||||
m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false)
|
||||
m.screen = screenCameras
|
||||
m.cursor = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
switch m.screen {
|
||||
case screenMenu:
|
||||
return m.updateMenu(msg)
|
||||
case screenCameras:
|
||||
return m.updateCameras(msg)
|
||||
case screenLayouts:
|
||||
return m.updateLayouts(msg)
|
||||
case screenLayoutEdit:
|
||||
return m.updateLayoutEdit(msg)
|
||||
case screenCameraPicker:
|
||||
return m.updateCameraPicker(msg)
|
||||
case screenSetActive:
|
||||
return m.updateSetActive(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// menu -------------------------------------------------------------
|
||||
|
||||
const (
|
||||
menuCameras = iota
|
||||
menuLayouts
|
||||
menuSetActive
|
||||
menuDiscover
|
||||
menuDiscoverEnable
|
||||
menuSave
|
||||
menuQuit
|
||||
)
|
||||
|
||||
var menuItems = []string{
|
||||
menuCameras: "Cameras",
|
||||
menuLayouts: "Layouts",
|
||||
menuSetActive: "Set active layout",
|
||||
menuDiscover: "Discover from UniFi Protect",
|
||||
menuDiscoverEnable: "Discover + enable RTSP (high)",
|
||||
menuSave: "Save",
|
||||
menuQuit: "Quit",
|
||||
}
|
||||
|
||||
func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, len(menuItems))
|
||||
case "down", "j":
|
||||
m.moveCursor(1, len(menuItems))
|
||||
case "enter", "l", " ":
|
||||
switch m.cursor {
|
||||
case menuCameras:
|
||||
m.screen, m.cursor = screenCameras, 0
|
||||
case menuLayouts:
|
||||
m.screen, m.cursor = screenLayouts, 0
|
||||
case menuSetActive:
|
||||
m.screen, m.cursor = screenSetActive, 0
|
||||
case menuDiscover:
|
||||
m.setStatus("discovering…", false)
|
||||
return m, m.discoverCmd("")
|
||||
case menuDiscoverEnable:
|
||||
m.setStatus("discovering and enabling RTSP…", false)
|
||||
return m, m.discoverCmd("high")
|
||||
case menuSave:
|
||||
return m, m.save()
|
||||
case menuQuit:
|
||||
return m, m.quit()
|
||||
}
|
||||
case "q":
|
||||
return m, m.quit()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// cameras ----------------------------------------------------------
|
||||
|
||||
func (m *model) updateCameras(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
n := len(m.cfg.Cameras)
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, n)
|
||||
case "down", "j":
|
||||
m.moveCursor(1, n)
|
||||
case "d":
|
||||
if n > 0 {
|
||||
m.cfg.Cameras[m.cursor].Disabled = !m.cfg.Cameras[m.cursor].Disabled
|
||||
m.dirty = true
|
||||
}
|
||||
case "x":
|
||||
if n > 0 {
|
||||
m.cfg.Cameras = append(m.cfg.Cameras[:m.cursor], m.cfg.Cameras[m.cursor+1:]...)
|
||||
if m.cursor >= len(m.cfg.Cameras) && m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// layouts ----------------------------------------------------------
|
||||
|
||||
func (m *model) updateLayouts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
n := len(m.cfg.Layouts)
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, n)
|
||||
case "down", "j":
|
||||
m.moveCursor(1, n)
|
||||
case "enter", "l":
|
||||
if n > 0 {
|
||||
m.editLayout = m.cursor
|
||||
m.migrateToTiles(m.editLayout)
|
||||
m.edCol, m.edRow = 0, 0
|
||||
m.screen, m.cursor = screenLayoutEdit, 0
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// (grid-editor update logic lives in gridedit.go)
|
||||
|
||||
// camera picker ----------------------------------------------------
|
||||
|
||||
// pickerOptions is "(empty)" followed by enabled camera names.
|
||||
func (m *model) pickerOptions() []string {
|
||||
opts := []string{"(empty)"}
|
||||
for _, c := range m.cfg.Cameras {
|
||||
if c.Disabled {
|
||||
continue
|
||||
}
|
||||
opts = append(opts, c.Name)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (m *model) updateCameraPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
opts := m.pickerOptions()
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenLayoutEdit, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, len(opts))
|
||||
case "down", "j":
|
||||
m.moveCursor(1, len(opts))
|
||||
case "enter", "l":
|
||||
choice := ""
|
||||
if m.cursor > 0 {
|
||||
choice = opts[m.cursor]
|
||||
}
|
||||
m.assignCamera(choice)
|
||||
m.screen, m.cursor = screenLayoutEdit, 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// set active -------------------------------------------------------
|
||||
|
||||
func (m *model) updateSetActive(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
n := len(m.cfg.Layouts)
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, n)
|
||||
case "down", "j":
|
||||
m.moveCursor(1, n)
|
||||
case "enter", "l":
|
||||
if n > 0 {
|
||||
m.cfg.ActiveLayout = m.cfg.Layouts[m.cursor].Name
|
||||
m.dirty = true
|
||||
m.setStatus("active layout set to "+m.cfg.ActiveLayout, false)
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// helpers ----------------------------------------------------------
|
||||
|
||||
func (m *model) moveCursor(delta, n int) {
|
||||
if n == 0 {
|
||||
m.cursor = 0
|
||||
return
|
||||
}
|
||||
m.cursor = (m.cursor + delta + n) % n
|
||||
}
|
||||
|
||||
func (m *model) cameraByID(id string) *config.Camera {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range m.cfg.Cameras {
|
||||
if m.cfg.Cameras[i].ID == id {
|
||||
return &m.cfg.Cameras[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) setStatus(s string, isErr bool) {
|
||||
m.status, m.isError = s, isErr
|
||||
}
|
||||
|
||||
func (m *model) save() tea.Cmd {
|
||||
cfgPath, cfg := m.cfgPath, m.cfg
|
||||
return func() tea.Msg {
|
||||
if err := config.Save(cfgPath, cfg); err != nil {
|
||||
return savedMsg{err: err}
|
||||
}
|
||||
// Best-effort: tell a running daemon to reload.
|
||||
_, err := ipc.Send(ipc.Request{Cmd: "reload"})
|
||||
return savedMsg{reloaded: err == nil}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) quit() tea.Cmd {
|
||||
return tea.Quit
|
||||
}
|
||||
Reference in New Issue
Block a user