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."""
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.protocol import (
CarryoutG2Protocol,
@ -16,11 +25,19 @@ __all__ = [
"AntennaConfig",
"BirdcageAntenna",
"CarryoutG2Protocol",
"CraftClient",
"CraftTrackingState",
"DemoCraftClient",
"DemoDevice",
"FirmwareProtocol",
"HAL000Protocol",
"HAL205Protocol",
"PassPrediction",
"Position",
"RssiReading",
"RotctldServer",
"SearchResult",
"SerialBridge",
"TargetPosition",
"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
minimize unnecessary q-then-reenter transitions.
"""
@ -53,7 +53,7 @@ _MENU_COMMANDS: dict[Menu, str] = {
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.
The bridge tracks the current firmware submenu so it can skip
@ -68,7 +68,7 @@ class SerialBridge:
self._connected = False
# ------------------------------------------------------------------
# Menu prompt string mapping for status display
# Menu prompt -> string mapping for status display
# ------------------------------------------------------------------
_MENU_PROMPTS: dict[Menu, str] = {
@ -297,7 +297,7 @@ class SerialBridge:
"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)
for motor_id, val in vel_matches:
if motor_id == "0":
@ -305,7 +305,7 @@ class SerialBridge:
elif motor_id == "1":
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)
for motor_id, val in acc_matches:
if motor_id == "0":
@ -316,7 +316,7 @@ class SerialBridge:
return result
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:
self._ensure_menu(Menu.MOT)
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:
"""Set max acceleration for a motor axis.
Firmware: MOT> ma [motor] [accel] (°/).
Firmware: MOT> ma [motor] [accel] (deg/s^2).
"""
with self._lock:
self._ensure_menu(Menu.MOT)
@ -460,7 +460,7 @@ class SerialBridge:
Args:
start_az: Starting azimuth 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.
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
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).
"""

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
dish with motor movement, RSSI signal modeling, and canned firmware responses.
@ -16,7 +16,7 @@ import random
import time
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):
@ -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))
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
if phase > 0.5:
return 0.0, -10.0 # Below horizon
# 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
# 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
az = 90.0 + 180.0 * arc_phase
el = max_el * math.sin(math.pi * arc_phase)
return az, el

View File

@ -102,15 +102,14 @@ class BirdcageApp(App):
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
from birdcage.demo import DemoDevice
self.device = DemoDevice()
self.device.connect()
else:
from birdcage.bridge import SerialBridge
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)
@ -142,11 +141,11 @@ class BirdcageApp(App):
def _setup_craft_client(self) -> None:
"""Create a Craft API client and hand it to the control screen."""
if self.demo_mode:
from birdcage_tui.demo import DemoCraftClient
from birdcage.demo import DemoCraftClient
client = DemoCraftClient()
else:
from birdcage_tui.craft_client import CraftClient
from birdcage.craft_client import CraftClient
client = CraftClient(base_url=self.craft_url)
try:

View File

@ -9,6 +9,7 @@ import contextlib
import logging
import threading
from birdcage.craft_client import CraftClient, CraftTrackingState
from textual import work
from textual.app import ComposeResult
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.worker import Worker
from birdcage_tui.craft_client import CraftClient, CraftTrackingState
from birdcage_tui.rotctld_server import TrackingState, TuiRotctldServer
from birdcage_tui.widgets.compass_rose import CompassRose
from birdcage_tui.widgets.craft_panel import CraftPanel

View File

@ -13,7 +13,7 @@ from unittest.mock import MagicMock
import pytest
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.widgets.craft_panel import CraftPanel, CraftTrackingStatus