birdcage/tui/src/birdcage_tui/app.py

166 lines
5.9 KiB
Python
Raw Normal View History

"""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()