Add Craft mode — direct satellite tracking via space.warehack.ing API
New F2 Control sub-mode that searches the Craft orbital catalog (22k+ objects), displays pass predictions, and drives the dish in real-time using server-computed AZ/EL positions from /api/sky/up. Tracking loop polls at ~1Hz, filters for the tracked target by target_type + target_id (str, not int — handles satellites, planets, stars, comets), and issues motor commands through the existing serial bridge. Verified end-to-end with Carryout G2 hardware tracking NOAA 17. New files: - craft_client.py — stdlib HTTP client (urllib only, no deps) - widgets/craft_panel.py — search table, pass info, tracking status - tests/test_craft_mode.py — 5 unit tests with mocked API - tests/test_craft_integration.py — 3 hardware integration tests
This commit is contained in:
parent
a249c98208
commit
6c1e9da773
9 changed files with 1379 additions and 85 deletions
259
tui/tests/test_craft_mode.py
Normal file
259
tui/tests/test_craft_mode.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Test Craft mode — API-driven satellite tracking via the F2 Control screen.
|
||||
|
||||
Verifies search, pass predictions, tracking loop, and stop lifecycle using
|
||||
mocked CraftClient methods. No real network calls.
|
||||
|
||||
NOTE: Uses post_message() for reliable button activation (same pattern as
|
||||
test_track_mode.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from birdcage_tui.app import BirdcageApp
|
||||
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition
|
||||
from birdcage_tui.screens.control import ControlScreen
|
||||
from birdcage_tui.widgets.craft_panel import CraftPanel, CraftTrackingStatus
|
||||
|
||||
|
||||
async def _switch_to_craft(pilot, app) -> None:
|
||||
"""Navigate to F2 Control > Craft sub-mode."""
|
||||
await pilot.press("f2")
|
||||
await pilot.pause()
|
||||
control = app.query_one("#control", ControlScreen)
|
||||
control.switch_mode("craft")
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
def _mock_search_results() -> list[SearchResult]:
|
||||
return [
|
||||
SearchResult(
|
||||
name="ISS (ZARYA)",
|
||||
target_type="satellite",
|
||||
target_id="25544",
|
||||
score=1.0,
|
||||
groups=["stations"],
|
||||
),
|
||||
SearchResult(
|
||||
name="NOAA 19",
|
||||
target_type="satellite",
|
||||
target_id="33591",
|
||||
score=0.8,
|
||||
groups=["weather"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _mock_passes() -> list[PassPrediction]:
|
||||
return [
|
||||
PassPrediction(
|
||||
satellite_name="ISS (ZARYA)",
|
||||
norad_id=25544,
|
||||
aos_time="2026-02-16T08:20:00Z",
|
||||
aos_az=220.0,
|
||||
tca_time="2026-02-16T08:25:00Z",
|
||||
tca_alt=45.3,
|
||||
tca_az=180.0,
|
||||
los_time="2026-02-16T08:30:00Z",
|
||||
los_az=140.0,
|
||||
max_elevation=45.3,
|
||||
duration_seconds=600,
|
||||
is_visible=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _mock_visible_target() -> list[TargetPosition]:
|
||||
return [
|
||||
TargetPosition(
|
||||
name="ISS (ZARYA)",
|
||||
target_type="satellite",
|
||||
target_id="25544",
|
||||
azimuth=245.30,
|
||||
altitude=34.20,
|
||||
distance_km=420.0,
|
||||
range_rate=-2.1,
|
||||
is_above_horizon=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _mock_below_horizon() -> list[TargetPosition]:
|
||||
"""Return an empty list — target is not in the sky."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_populates_table():
|
||||
"""Searching should populate the DataTable with results."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await _switch_to_craft(pilot, app)
|
||||
|
||||
control = app.query_one("#control", ControlScreen)
|
||||
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
|
||||
|
||||
# Mock the client
|
||||
mock_client = MagicMock()
|
||||
mock_client.search.return_value = _mock_search_results()
|
||||
control.set_craft_client(mock_client)
|
||||
|
||||
# Post search request
|
||||
panel.post_message(CraftPanel.SearchRequested("ISS"))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Verify table was populated
|
||||
from textual.widgets import DataTable
|
||||
|
||||
table = app.query_one("#craft-results-table", DataTable)
|
||||
assert table.row_count == 2
|
||||
mock_client.search.assert_called_once_with("ISS")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracking_drives_device():
|
||||
"""Tracking should poll positions and move the demo device."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await _switch_to_craft(pilot, app)
|
||||
|
||||
control = app.query_one("#control", ControlScreen)
|
||||
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_visible_targets.return_value = _mock_visible_target()
|
||||
control.set_craft_client(mock_client)
|
||||
|
||||
# Start tracking
|
||||
panel.post_message(
|
||||
CraftPanel.TrackRequested(
|
||||
target_type="satellite",
|
||||
target_id="25544",
|
||||
name="ISS (ZARYA)",
|
||||
min_el=18.0,
|
||||
)
|
||||
)
|
||||
# Let the tracking loop run a couple iterations
|
||||
await asyncio.sleep(2.5)
|
||||
|
||||
# Device target should have been updated
|
||||
assert app.device._target_az == pytest.approx(245.30, abs=0.1)
|
||||
assert app.device._target_el == pytest.approx(34.20, abs=0.1)
|
||||
|
||||
# Status should show TRACKING
|
||||
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
|
||||
assert status.state == "TRACKING"
|
||||
assert status.moves >= 1
|
||||
|
||||
# Cleanup
|
||||
control._stop_craft_tracking()
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracking_stops_cleanly():
|
||||
"""Stopping tracking should return to IDLE state."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await _switch_to_craft(pilot, app)
|
||||
|
||||
control = app.query_one("#control", ControlScreen)
|
||||
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_visible_targets.return_value = _mock_visible_target()
|
||||
control.set_craft_client(mock_client)
|
||||
|
||||
# Start tracking
|
||||
panel.post_message(
|
||||
CraftPanel.TrackRequested(
|
||||
target_type="satellite",
|
||||
target_id="25544",
|
||||
name="ISS (ZARYA)",
|
||||
min_el=18.0,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# Stop
|
||||
panel.post_message(CraftPanel.StopTrackingRequested())
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
|
||||
assert status.state == "IDLE"
|
||||
assert not control._craft_tracking
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_below_horizon_shows_waiting():
|
||||
"""Target not in sky/up results should show WAITING status."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await _switch_to_craft(pilot, app)
|
||||
|
||||
control = app.query_one("#control", ControlScreen)
|
||||
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_visible_targets.return_value = _mock_below_horizon()
|
||||
control.set_craft_client(mock_client)
|
||||
|
||||
# Start tracking
|
||||
panel.post_message(
|
||||
CraftPanel.TrackRequested(
|
||||
target_type="satellite",
|
||||
target_id="25544",
|
||||
name="ISS (ZARYA)",
|
||||
min_el=18.0,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
|
||||
assert status.state == "WAITING"
|
||||
|
||||
# Cleanup
|
||||
control._stop_craft_tracking()
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_display():
|
||||
"""Pass predictions should update the CraftPassInfo widget."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await _switch_to_craft(pilot, app)
|
||||
|
||||
control = app.query_one("#control", ControlScreen)
|
||||
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_passes.return_value = _mock_passes()
|
||||
control.set_craft_client(mock_client)
|
||||
|
||||
# Request passes
|
||||
panel.post_message(CraftPanel.PassesRequested(norad_id=25544))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
from birdcage_tui.widgets.craft_panel import CraftPassInfo
|
||||
|
||||
info = app.query_one("#craft-pass-info", CraftPassInfo)
|
||||
assert "45.3" in info.passes_text
|
||||
mock_client.get_passes.assert_called_once_with(25544)
|
||||
Loading…
Add table
Add a link
Reference in a new issue