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:
parent
3013eeee4c
commit
16ca4892b3
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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] (°/s²).
|
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.
|
||||||
|
|
||||||
@ -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).
|
||||||
"""
|
"""
|
||||||
@ -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
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user