// 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 " " 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 }