"""Birdcage TUI — main application shell. ContentSwitcher-based layout with sidebar navigation (F1-F5), device status bar, and five swappable screen panels. """ import argparse import logging from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.widgets import Button, ContentSwitcher, Footer, Header, Static from birdcage_tui.screens.console import ConsoleScreen from birdcage_tui.screens.position import PositionScreen from birdcage_tui.screens.scan import ScanScreen from birdcage_tui.screens.signal import SignalScreen from birdcage_tui.screens.system import SystemScreen from birdcage_tui.widgets.device_status_bar import DeviceStatusBar log = logging.getLogger(__name__) MODES: dict[str, tuple[str, type]] = { "position": ("F1 Position", PositionScreen), "signal": ("F2 Signal", SignalScreen), "scan": ("F3 Scan", ScanScreen), "system": ("F4 System", SystemScreen), "console": ("F5 Console", ConsoleScreen), } class BirdcageApp(App): """Textual application for Winegard satellite dish control.""" TITLE = "Birdcage" CSS_PATH = "theme.tcss" BINDINGS = [ Binding("f1", "switch_mode('position')", "Position"), Binding("f2", "switch_mode('signal')", "Signal"), Binding("f3", "switch_mode('scan')", "Scan"), Binding("f4", "switch_mode('system')", "System"), Binding("f5", "switch_mode('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 @property def SUB_TITLE(self) -> str: # noqa: N802 if self.demo_mode: return "DEMO" return self.serial_port def compose(self) -> ComposeResult: yield Header() with Horizontal(id="main-area"): with Vertical(id="sidebar"): yield Static("\U0001f6f0\ufe0f Birdcage", classes="sidebar-title") yield Static("Carryout G2", classes="sidebar-subtitle") for mode_key, (label, _) in MODES.items(): yield Button(label, id=f"btn-{mode_key}", classes="sidebar-btn") yield DeviceStatusBar(id="device-status") with ContentSwitcher(id="content-area", initial="position"): for mode_key, (_, screen_cls) in MODES.items(): yield screen_cls(id=mode_key) yield Footer() def on_mount(self) -> None: self.query_one("#btn-position").add_class("active") self._setup_device() 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() 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 mode_key in MODES: screen = self.query_one(f"#{mode_key}") if hasattr(screen, "set_device"): screen.set_device(self.device) status_bar = self.query_one("#device-status", DeviceStatusBar) if hasattr(status_bar, "set_device"): status_bar.set_device(self.device) def action_switch_mode(self, mode: str) -> None: """Switch the content area to *mode* and update sidebar highlight.""" switcher = self.query_one("#content-area", ContentSwitcher) switcher.current = mode for btn in self.query(".sidebar-btn"): btn.remove_class("active") self.query_one(f"#btn-{mode}").add_class("active") screen = self.query_one(f"#{mode}") if hasattr(screen, "on_show"): screen.on_show() def action_toggle_dark(self) -> None: self.dark = not self.dark def on_button_pressed(self, event: Button.Pressed) -> None: button_id = event.button.id or "" if button_id.startswith("btn-"): mode = button_id.removeprefix("btn-") if mode in MODES: self.action_switch_mode(mode) 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 app.run()