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:
parent
a70b9b0a29
commit
7271b53c63
23 changed files with 4160 additions and 0 deletions
165
tui/src/birdcage_tui/app.py
Normal file
165
tui/src/birdcage_tui/app.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue