Add Birdcage TUI: 5-screen Textual interface for Carryout G2

F1 Position (compass rose, motor control, sparklines),
F2 Signal (RSSI gauge with sub-char precision, DVB/ADC sparklines, LNA toggle),
F3 Scan (AZ/EL grid sweep with heatmap and CSV export),
F4 System (NVS table, A3981 diagnostics, motor dynamics),
F5 Console (raw serial terminal with prompt detection and safety gates).

Includes SerialBridge (thread-safe protocol wrapper), DemoDevice
(synthetic simulation for --demo mode), dark RF theme with rounded
borders and teal accents, and send_raw() on CarryoutG2Protocol.
This commit is contained in:
Ryan Malloy 2026-02-13 08:53:03 -07:00
parent a70b9b0a29
commit 7271b53c63
23 changed files with 4160 additions and 0 deletions

165
tui/src/birdcage_tui/app.py Normal file
View file

@ -0,0 +1,165 @@
"""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()