166 lines
5.9 KiB
Python
166 lines
5.9 KiB
Python
|
|
"""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()
|