From 16ca4892b3f208eeeb6e1121649efdd942bec0d8 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 17 Feb 2026 16:01:38 -0700 Subject: [PATCH] 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. --- src/birdcage/__init__.py | 17 +++++++++++++++++ .../birdcage_tui => src/birdcage}/bridge.py | 18 +++++++++--------- .../birdcage}/craft_client.py | 2 +- {tui/src/birdcage_tui => src/birdcage}/demo.py | 10 +++++----- tui/src/birdcage_tui/app.py | 9 ++++----- tui/src/birdcage_tui/screens/control.py | 2 +- tui/tests/test_craft_mode.py | 2 +- 7 files changed, 38 insertions(+), 22 deletions(-) rename {tui/src/birdcage_tui => src/birdcage}/bridge.py (94%) rename {tui/src/birdcage_tui => src/birdcage}/craft_client.py (96%) rename {tui/src/birdcage_tui => src/birdcage}/demo.py (96%) diff --git a/src/birdcage/__init__.py b/src/birdcage/__init__.py index b4e69c8..68f5fa9 100644 --- a/src/birdcage/__init__.py +++ b/src/birdcage/__init__.py @@ -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", ] diff --git a/tui/src/birdcage_tui/bridge.py b/src/birdcage/bridge.py similarity index 94% rename from tui/src/birdcage_tui/bridge.py rename to src/birdcage/bridge.py index f922632..6744e25 100644 --- a/tui/src/birdcage_tui/bridge.py +++ b/src/birdcage/bridge.py @@ -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] (°/s²). + 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. diff --git a/tui/src/birdcage_tui/craft_client.py b/src/birdcage/craft_client.py similarity index 96% rename from tui/src/birdcage_tui/craft_client.py rename to src/birdcage/craft_client.py index 78b5fdd..29dabdf 100644 --- a/tui/src/birdcage_tui/craft_client.py +++ b/src/birdcage/craft_client.py @@ -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). """ diff --git a/tui/src/birdcage_tui/demo.py b/src/birdcage/demo.py similarity index 96% rename from tui/src/birdcage_tui/demo.py rename to src/birdcage/demo.py index d03e39f..fdf5a9e 100644 --- a/tui/src/birdcage_tui/demo.py +++ b/src/birdcage/demo.py @@ -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 diff --git a/tui/src/birdcage_tui/app.py b/tui/src/birdcage_tui/app.py index 9a428fe..5d15ae0 100644 --- a/tui/src/birdcage_tui/app.py +++ b/tui/src/birdcage_tui/app.py @@ -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: diff --git a/tui/src/birdcage_tui/screens/control.py b/tui/src/birdcage_tui/screens/control.py index f7c8c44..54c3511 100644 --- a/tui/src/birdcage_tui/screens/control.py +++ b/tui/src/birdcage_tui/screens/control.py @@ -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 diff --git a/tui/tests/test_craft_mode.py b/tui/tests/test_craft_mode.py index 0ac9925..5a8dd11 100644 --- a/tui/tests/test_craft_mode.py +++ b/tui/tests/test_craft_mode.py @@ -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