Promote bridge, demo, craft_client to core birdcage package

Move bridge.py, demo.py, craft_client.py from tui/src/birdcage_tui/ to
src/birdcage/ so both TUI and MCP server can share the device layer
without a circular dependency on textual.
This commit is contained in:
Ryan Malloy 2026-02-17 16:01:38 -07:00
parent 3013eeee4c
commit 16ca4892b3
7 changed files with 38 additions and 22 deletions

View File

@ -1,6 +1,15 @@
"""birdcage: Winegard satellite dish control for amateur radio sky tracking.""" """birdcage: Winegard satellite dish control for amateur radio sky tracking."""
from birdcage.antenna import AntennaConfig, BirdcageAntenna from birdcage.antenna import AntennaConfig, BirdcageAntenna
from birdcage.bridge import SerialBridge
from birdcage.craft_client import (
CraftClient,
CraftTrackingState,
PassPrediction,
SearchResult,
TargetPosition,
)
from birdcage.demo import DemoCraftClient, DemoDevice
from birdcage.leapfrog import apply_leapfrog from birdcage.leapfrog import apply_leapfrog
from birdcage.protocol import ( from birdcage.protocol import (
CarryoutG2Protocol, CarryoutG2Protocol,
@ -16,11 +25,19 @@ __all__ = [
"AntennaConfig", "AntennaConfig",
"BirdcageAntenna", "BirdcageAntenna",
"CarryoutG2Protocol", "CarryoutG2Protocol",
"CraftClient",
"CraftTrackingState",
"DemoCraftClient",
"DemoDevice",
"FirmwareProtocol", "FirmwareProtocol",
"HAL000Protocol", "HAL000Protocol",
"HAL205Protocol", "HAL205Protocol",
"PassPrediction",
"Position", "Position",
"RssiReading", "RssiReading",
"RotctldServer", "RotctldServer",
"SearchResult",
"SerialBridge",
"TargetPosition",
"apply_leapfrog", "apply_leapfrog",
] ]

View File

@ -1,6 +1,6 @@
"""Thread-safe bridge between Birdcage TUI and CarryoutG2Protocol. """Thread-safe bridge between consumers and CarryoutG2Protocol.
Wraps all serial I/O in a threading.Lock so the TUI's worker threads Wraps all serial I/O in a threading.Lock so concurrent callers
don't stomp on each other. Tracks the current firmware submenu to don't stomp on each other. Tracks the current firmware submenu to
minimize unnecessary q-then-reenter transitions. minimize unnecessary q-then-reenter transitions.
""" """
@ -53,7 +53,7 @@ _MENU_COMMANDS: dict[Menu, str] = {
class SerialBridge: class SerialBridge:
"""Thread-safe wrapper around CarryoutG2Protocol for TUI consumption. """Thread-safe wrapper around CarryoutG2Protocol.
All public methods acquire a lock before touching the serial port. All public methods acquire a lock before touching the serial port.
The bridge tracks the current firmware submenu so it can skip The bridge tracks the current firmware submenu so it can skip
@ -68,7 +68,7 @@ class SerialBridge:
self._connected = False self._connected = False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Menu prompt string mapping for status display # Menu prompt -> string mapping for status display
# ------------------------------------------------------------------ # ------------------------------------------------------------------
_MENU_PROMPTS: dict[Menu, str] = { _MENU_PROMPTS: dict[Menu, str] = {
@ -297,7 +297,7 @@ class SerialBridge:
"el_accel": 0.0, "el_accel": 0.0,
} }
# mv "Max Vel [0] = 65.0 Max Vel [1] = 45.0" or similar # mv -> "Max Vel [0] = 65.0 Max Vel [1] = 45.0" or similar
vel_matches = re.findall(r"Max Vel\s*\[(\d)\]\s*=\s*(-?\d+\.?\d*)", mv_resp) vel_matches = re.findall(r"Max Vel\s*\[(\d)\]\s*=\s*(-?\d+\.?\d*)", mv_resp)
for motor_id, val in vel_matches: for motor_id, val in vel_matches:
if motor_id == "0": if motor_id == "0":
@ -305,7 +305,7 @@ class SerialBridge:
elif motor_id == "1": elif motor_id == "1":
result["el_max_vel"] = float(val) result["el_max_vel"] = float(val)
# ma "Accel[0] = 400.0 Accel[1] = 400.0" # ma -> "Accel[0] = 400.0 Accel[1] = 400.0"
acc_matches = re.findall(r"Accel\[(\d)\]\s*=\s*(-?\d+\.?\d*)", ma_resp) acc_matches = re.findall(r"Accel\[(\d)\]\s*=\s*(-?\d+\.?\d*)", ma_resp)
for motor_id, val in acc_matches: for motor_id, val in acc_matches:
if motor_id == "0": if motor_id == "0":
@ -316,7 +316,7 @@ class SerialBridge:
return result return result
def set_max_velocity(self, motor_id: int, deg_per_sec: float) -> None: def set_max_velocity(self, motor_id: int, deg_per_sec: float) -> None:
"""Set max velocity for a motor axis (°/s). Firmware: MOT> mv [motor] [vel].""" """Set max velocity for a motor axis (deg/s)."""
with self._lock: with self._lock:
self._ensure_menu(Menu.MOT) self._ensure_menu(Menu.MOT)
self._send(f"mv {motor_id} {deg_per_sec:.1f}") self._send(f"mv {motor_id} {deg_per_sec:.1f}")
@ -324,7 +324,7 @@ class SerialBridge:
def set_max_acceleration(self, motor_id: int, accel: float) -> None: def set_max_acceleration(self, motor_id: int, accel: float) -> None:
"""Set max acceleration for a motor axis. """Set max acceleration for a motor axis.
Firmware: MOT> ma [motor] [accel] (°/). Firmware: MOT> ma [motor] [accel] (deg/s^2).
""" """
with self._lock: with self._lock:
self._ensure_menu(Menu.MOT) self._ensure_menu(Menu.MOT)
@ -460,7 +460,7 @@ class SerialBridge:
Args: Args:
start_az: Starting azimuth in degrees. start_az: Starting azimuth in degrees.
span: Total sweep width in degrees. span: Total sweep width in degrees.
step_cdeg: Step size in centidegrees (100 = 1.00°). step_cdeg: Step size in centidegrees (100 = 1.00 deg).
num_xponders: Number of transponders to cycle per position. num_xponders: Number of transponders to cycle per position.
timeout: Serial read timeout for the long-running command. timeout: Serial read timeout for the long-running command.

View File

@ -2,7 +2,7 @@
Provides satellite search, pass predictions, and real-time sky positions Provides satellite search, pass predictions, and real-time sky positions
from the Craft orbital mechanics API. All methods are blocking designed from the Craft orbital mechanics API. All methods are blocking designed
to be called from @work(thread=True) workers in the TUI. to be called from worker threads.
Uses urllib.request + json only (no requests/httpx dependency). Uses urllib.request + json only (no requests/httpx dependency).
""" """

View File

@ -1,4 +1,4 @@
"""Synthetic demo device for the Birdcage TUI. """Synthetic demo device for Birdcage.
Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2 Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2
dish with motor movement, RSSI signal modeling, and canned firmware responses. dish with motor movement, RSSI signal modeling, and canned firmware responses.
@ -16,7 +16,7 @@ import random
import time import time
from enum import Enum, auto from enum import Enum, auto
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition from birdcage.craft_client import PassPrediction, SearchResult, TargetPosition
class _DemoMenu(Enum): class _DemoMenu(Enum):
@ -812,14 +812,14 @@ def _leo_position(target_id: str, t: float) -> tuple[float, float]:
""" """
offset, period, max_el = _LEO_ARCS.get(target_id, (0.0, 10.0, 40.0)) offset, period, max_el = _LEO_ARCS.get(target_id, (0.0, 10.0, 40.0))
period_sec = period * 60.0 period_sec = period * 60.0
phase = ((t + offset * 60.0) % period_sec) / period_sec # 0.0 1.0 phase = ((t + offset * 60.0) % period_sec) / period_sec # 0.0 -> 1.0
# Visible window: phase 0.0-0.5 = above horizon, 0.5-1.0 = below # Visible window: phase 0.0-0.5 = above horizon, 0.5-1.0 = below
if phase > 0.5: if phase > 0.5:
return 0.0, -10.0 # Below horizon return 0.0, -10.0 # Below horizon
# Map 0→0.5 to AZ 90→270, EL 0→max→0 (sine arc) # Map 0->0.5 to AZ 90->270, EL 0->max->0 (sine arc)
arc_phase = phase / 0.5 # 0.0 1.0 through the visible pass arc_phase = phase / 0.5 # 0.0 -> 1.0 through the visible pass
az = 90.0 + 180.0 * arc_phase az = 90.0 + 180.0 * arc_phase
el = max_el * math.sin(math.pi * arc_phase) el = max_el * math.sin(math.pi * arc_phase)
return az, el return az, el

View File

@ -102,15 +102,14 @@ class BirdcageApp(App):
def _setup_device(self) -> None: def _setup_device(self) -> None:
"""Create device (demo or real) and hand it to each screen.""" """Create device (demo or real) and hand it to each screen."""
if self.demo_mode: if self.demo_mode:
from birdcage_tui.demo import DemoDevice from birdcage.demo import DemoDevice
self.device = DemoDevice() self.device = DemoDevice()
self.device.connect() self.device.connect()
else: else:
from birdcage.bridge import SerialBridge
from birdcage.protocol import get_protocol from birdcage.protocol import get_protocol
from birdcage_tui.bridge import SerialBridge
protocol = get_protocol(self.firmware_name) protocol = get_protocol(self.firmware_name)
self.device = SerialBridge(protocol) self.device = SerialBridge(protocol)
self.device.connect(self.serial_port) self.device.connect(self.serial_port)
@ -142,11 +141,11 @@ class BirdcageApp(App):
def _setup_craft_client(self) -> None: def _setup_craft_client(self) -> None:
"""Create a Craft API client and hand it to the control screen.""" """Create a Craft API client and hand it to the control screen."""
if self.demo_mode: if self.demo_mode:
from birdcage_tui.demo import DemoCraftClient from birdcage.demo import DemoCraftClient
client = DemoCraftClient() client = DemoCraftClient()
else: else:
from birdcage_tui.craft_client import CraftClient from birdcage.craft_client import CraftClient
client = CraftClient(base_url=self.craft_url) client = CraftClient(base_url=self.craft_url)
try: try:

View File

@ -9,6 +9,7 @@ import contextlib
import logging import logging
import threading import threading
from birdcage.craft_client import CraftClient, CraftTrackingState
from textual import work from textual import work
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
@ -16,7 +17,6 @@ from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Button, ContentSwitcher, Input, Static from textual.widgets import Button, ContentSwitcher, Input, Static
from textual.worker import Worker from textual.worker import Worker
from birdcage_tui.craft_client import CraftClient, CraftTrackingState
from birdcage_tui.rotctld_server import TrackingState, TuiRotctldServer from birdcage_tui.rotctld_server import TrackingState, TuiRotctldServer
from birdcage_tui.widgets.compass_rose import CompassRose from birdcage_tui.widgets.compass_rose import CompassRose
from birdcage_tui.widgets.craft_panel import CraftPanel from birdcage_tui.widgets.craft_panel import CraftPanel

View File

@ -13,7 +13,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from birdcage_tui.app import BirdcageApp from birdcage_tui.app import BirdcageApp
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition from birdcage.craft_client import PassPrediction, SearchResult, TargetPosition
from birdcage_tui.screens.control import ControlScreen from birdcage_tui.screens.control import ControlScreen
from birdcage_tui.widgets.craft_panel import CraftPanel, CraftTrackingStatus from birdcage_tui.widgets.craft_panel import CraftPanel, CraftTrackingStatus