Ryan Malloy 145763fcfb Rebuild TUI as 4-tab layout with 10 new widgets
Replace sidebar + 5-screen layout with horizontal tab bar (F1-F4)
and persistent StatusStrip. Consolidate Position + Scan screens into
Signal (F3) with Monitor/Sweep/Sky Map sub-modes via ModeBar.

Screens:
  - F1 Dashboard: system health, tracking panel, quick actions, presets
  - F2 Control: motor tuning, compass rose, preset management
  - F3 Signal: RSSI monitor, 1D sweep (SweepPlot), 2D sky map (heatmap)
  - F4 System: NVS editor with regex filter, EEPROM, firmware info
  - F5 Console: push/pop overlay (no longer a tab)

New widgets: StatusStrip, ModeBar, SweepPlot, QuickActions, PresetList,
ReceiverInfo, MotorTuning, NvsFilter, SystemHealth, TrackingPanel.

Removed: PositionScreen, ScanScreen, DeviceStatusBar (functionality
absorbed into new screens and StatusStrip).

App-level position poll feeds StatusStrip and active screen at ~2 Hz.
Fix shared threading.Event across instances (class-level mutable default).
2026-02-14 18:04:50 -07:00

329 lines
12 KiB
Python

"""Birdcage TUI — main application shell.
Horizontal tab bar (F1-F4) with persistent StatusStrip, ContentSwitcher
for four task-oriented screens, and F5 console overlay. App-level position
polling feeds the StatusStrip regardless of which tab is active.
"""
import argparse
import contextlib
import logging
import threading
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal
from textual.widgets import Button, ContentSwitcher, Footer, Header
from birdcage_tui.screens.console import ConsoleOverlay
from birdcage_tui.screens.control import ControlScreen
from birdcage_tui.screens.dashboard import DashboardScreen
from birdcage_tui.screens.signal import SignalScreen
from birdcage_tui.screens.system import SystemScreen
from birdcage_tui.widgets.status_strip import StatusStrip
log = logging.getLogger(__name__)
TABS: dict[str, tuple[str, type]] = {
"dashboard": ("F1 Dashboard", DashboardScreen),
"control": ("F2 Control", ControlScreen),
"signal": ("F3 Signal", SignalScreen),
"system": ("F4 System", SystemScreen),
}
class BirdcageApp(App):
"""Textual application for Winegard satellite dish control."""
TITLE = "Birdcage"
CSS_PATH = "theme.tcss"
BINDINGS = [
Binding("f1", "switch_tab('dashboard')", "Dashboard"),
Binding("f2", "switch_tab('control')", "Control"),
Binding("f3", "switch_tab('signal')", "Signal"),
Binding("f4", "switch_tab('system')", "System"),
Binding("f5", "toggle_console", "Console"),
Binding("q", "quit", "Quit"),
Binding("d", "toggle_dark", "Dark"),
]
# Set from CLI args before run()
demo_mode: bool = False
serial_port: str = "/dev/ttyUSB0"
firmware_name: str = "g2"
skip_init: bool = False
device: object = None
shutdown_event: threading.Event = threading.Event()
# App-level position cache (updated by background poll).
_current_az: float = 0.0
_current_el: float = 0.0
_prev_az: float = 0.0
_prev_el: float = 0.0
_console_visible: bool = False
@property
def SUB_TITLE(self) -> str: # noqa: N802
if self.demo_mode:
return "DEMO"
return self.serial_port
def compose(self) -> ComposeResult:
yield Header()
yield StatusStrip(id="status-strip")
with Horizontal(id="tab-bar"):
for tab_key, (label, _) in TABS.items():
yield Button(label, id=f"tab-{tab_key}", classes="tab-btn")
with ContentSwitcher(id="content-area", initial="dashboard"):
for tab_key, (_, screen_cls) in TABS.items():
yield screen_cls(id=tab_key)
yield Footer()
def on_mount(self) -> None:
# Fresh event per instance — class-level default is shared across instances.
self.shutdown_event = threading.Event()
self.query_one("#tab-dashboard").add_class("active")
self._setup_device()
# ------------------------------------------------------------------
# Device lifecycle
# ------------------------------------------------------------------
def _setup_device(self) -> None:
"""Create device (demo or real) and hand it to each screen."""
if self.demo_mode:
from birdcage_tui.demo import DemoDevice
self.device = DemoDevice()
self.device.connect()
else:
from birdcage.protocol import get_protocol
from birdcage_tui.bridge import SerialBridge
protocol = get_protocol(self.firmware_name)
self.device = SerialBridge(protocol)
self.device.connect(self.serial_port)
if not self.skip_init:
self.run_worker(self._initialize_device, thread=True)
self._distribute_device()
self._update_status_strip_connection()
self._install_console()
self._start_position_poll()
async def _initialize_device(self) -> None:
"""Run device init in a worker thread (blocks on serial I/O)."""
try:
self.device.initialize()
except Exception:
log.exception("Device initialization failed")
self.notify("Init failed -- check serial connection", severity="error")
def _distribute_device(self) -> None:
"""Pass the device reference to every screen that wants it."""
for tab_key in TABS:
screen = self.query_one(f"#{tab_key}")
if hasattr(screen, "set_device"):
screen.set_device(self.device)
def _update_status_strip_connection(self) -> None:
"""Set the status strip's connection info from current device."""
strip = self.query_one("#status-strip", StatusStrip)
is_demo = type(self.device).__name__ == "DemoDevice" if self.device else False
strip.demo = is_demo or self.demo_mode
strip.connected = (
self.device is not None
and getattr(self.device, "is_connected", False)
and not strip.demo
)
strip.port = self.serial_port
def _install_console(self) -> None:
"""Pre-install the console overlay so it persists across open/close."""
self.install_screen(ConsoleOverlay(), name="console-overlay")
# ------------------------------------------------------------------
# App-level position poll
# ------------------------------------------------------------------
@work(thread=True, exclusive=True, group="app-position-poll")
def _start_position_poll(self) -> None:
"""Poll device at ~2 Hz for position, update StatusStrip globally."""
shutdown = self.shutdown_event
while not shutdown.is_set():
if self.device is None:
shutdown.wait(0.5)
continue
try:
pos = self.device.get_position()
az = pos["azimuth"]
el = pos["elevation"]
self._current_az = az
self._current_el = el
self.call_from_thread(self._update_position, az, el)
except Exception:
log.debug("App position poll failed", exc_info=True)
shutdown.wait(0.5)
def _update_position(self, az: float, el: float) -> None:
"""Push position to StatusStrip and active screen."""
strip = self.query_one("#status-strip", StatusStrip)
strip.azimuth = az
strip.elevation = el
# Detect movement from position delta
delta = abs(az - self._prev_az) + abs(el - self._prev_el)
if delta > 0.05:
strip.motor_state = "MOVING"
else:
strip.motor_state = "IDLE"
self._prev_az = az
self._prev_el = el
# Notify active screen
switcher = self.query_one("#content-area", ContentSwitcher)
if switcher.current:
try:
active = self.query_one(f"#{switcher.current}")
if hasattr(active, "on_position_update"):
active.on_position_update(az, el)
except Exception:
pass
# ------------------------------------------------------------------
# Tab switching
# ------------------------------------------------------------------
def action_switch_tab(self, tab: str) -> None:
"""Switch the content area to *tab* and update tab bar highlight."""
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = tab
for btn in self.query(".tab-btn"):
btn.remove_class("active")
self.query_one(f"#tab-{tab}").add_class("active")
screen = self.query_one(f"#{tab}")
if hasattr(screen, "on_show"):
screen.on_show()
# ------------------------------------------------------------------
# Console overlay
# ------------------------------------------------------------------
def action_toggle_console(self) -> None:
"""Push or pop the console overlay."""
if self._console_visible:
# Pop the console -- dismiss triggers the callback
try:
self.pop_screen()
except Exception:
self._console_visible = False
else:
self.push_screen("console-overlay", callback=self._on_console_dismissed)
self._console_visible = True
def _on_console_dismissed(self, _result=None) -> None:
"""Called when the console overlay is dismissed."""
self._console_visible = False
# ------------------------------------------------------------------
# Tab bar button handling
# ------------------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id.startswith("tab-"):
tab = button_id.removeprefix("tab-")
if tab in TABS:
self.action_switch_tab(tab)
# ------------------------------------------------------------------
# QuickActions navigation (from Dashboard)
# ------------------------------------------------------------------
def on_quick_actions_action_selected(self, event) -> None:
"""Handle navigation requests from the Dashboard's quick actions."""
action = event.action
if action == "point":
self.action_switch_tab("control")
elif action == "monitor":
self.action_switch_tab("signal")
elif action == "scan":
self.action_switch_tab("signal")
# Tell the signal screen to switch to skymap mode
try:
signal_screen = self.query_one("#signal")
if hasattr(signal_screen, "switch_mode"):
signal_screen.switch_mode("skymap")
except Exception:
pass
elif action == "stow":
self._do_stow()
def _do_stow(self) -> None:
"""Move dish to stow position (0, 65)."""
if self.device is None:
self.notify("No device connected", severity="warning")
return
self.notify("Stowing dish to AZ=0 EL=65...", severity="information")
self._run_stow()
@work(thread=True)
def _run_stow(self) -> None:
"""Execute stow in a worker thread."""
try:
self.device.move_to(0.0, 65.0)
self.call_from_thread(self.notify, "Stow command sent")
except Exception:
log.exception("Stow failed")
self.call_from_thread(self.notify, "Stow failed", severity="error")
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def on_unmount(self) -> None:
"""Signal all worker threads to exit and disconnect the device."""
self.shutdown_event.set()
if self.device and hasattr(self.device, "disconnect"):
self.device.disconnect()
def main() -> None:
parser = argparse.ArgumentParser(
description="Birdcage TUI -- Satellite Dish Control"
)
parser.add_argument("--demo", action="store_true", help="Run with simulated device")
parser.add_argument("--port", default="/dev/ttyUSB0", help="Serial port")
parser.add_argument(
"--firmware",
default="g2",
choices=["g2", "hal205", "hal000"],
help="Firmware version",
)
parser.add_argument(
"--skip-init", action="store_true", help="Skip firmware initialization"
)
args = parser.parse_args()
app = BirdcageApp()
app.demo_mode = args.demo
app.serial_port = args.port
app.firmware_name = args.firmware
app.skip_init = args.skip_init
try:
app.run()
except KeyboardInterrupt:
pass
finally:
app.shutdown_event.set()
with contextlib.suppress(Exception):
if app.device and hasattr(app.device, "disconnect"):
app.device.disconnect()