Add DemoCraftClient for complete offline demo mode

- DemoCraftClient in demo.py: duck-typed CraftClient replacement with
  8 canned satellites, synthetic pass predictions, and time-varying LEO
  arcs that drive real AOS/TCA/LOS pass events to the camera overlay
- Fix sky map EL signal fidelity: snap _el to _target_el at sweep start
  so 2D scans compute correct per-row RSSI (was using stale elevation)
- Branch on demo_mode in app.py _setup_craft_client() to inject
  DemoCraftClient instead of the HTTP CraftClient
- Add test_demo_craft.py: 5 tests exercising search, passes, tracking,
  and WAITING state through the full TUI without mocks
- Update take_screenshots.py to cover all 8 screens (dashboard, control,
  craft search, craft tracking, signal, system, console, camera)
This commit is contained in:
Ryan Malloy 2026-02-16 11:49:39 -07:00
parent 7035d814a1
commit 3013eeee4c
4 changed files with 518 additions and 13 deletions

View File

@ -1,21 +1,31 @@
"""Take screenshots of all TUI screens in demo mode for documentation.""" """Take screenshots of all TUI screens in demo mode for documentation.
Generates SVG snapshots of every screen and sub-mode including overlays.
All data comes from DemoDevice and DemoCraftClient no hardware or
network required.
Usage:
cd tui && uv run python scripts/take_screenshots.py
"""
import asyncio import asyncio
from birdcage_tui.app import BirdcageApp from birdcage_tui.app import BirdcageApp
from birdcage_tui.screens.control import ControlScreen
SCREENS = { from birdcage_tui.widgets.craft_panel import CraftPanel
"f1": "dashboard",
"f2": "control",
"f3": "signal",
"f4": "system",
}
OUT_DIR = "/home/rpm/claude/ham/satellite/winegard-travler/site/public/screenshots" OUT_DIR = "/home/rpm/claude/ham/satellite/winegard-travler/site/public/screenshots"
async def main(): async def _screenshot_basic_tabs():
for key, name in SCREENS.items(): """F1 Dashboard, F2 Control (manual), F3 Signal, F4 System."""
tabs = {
"f1": "dashboard",
"f2": "control",
"f3": "signal",
"f4": "system",
}
for key, name in tabs.items():
app = BirdcageApp() app = BirdcageApp()
app.demo_mode = True app.demo_mode = True
@ -23,7 +33,6 @@ async def main():
await pilot.pause() await pilot.pause()
await pilot.press(key) await pilot.press(key)
await pilot.pause() await pilot.pause()
# Let workers populate data.
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
path = f"{OUT_DIR}/tui-{name}.svg" path = f"{OUT_DIR}/tui-{name}.svg"
@ -31,4 +40,107 @@ async def main():
print(f"Saved {path}") print(f"Saved {path}")
async def _screenshot_craft_search():
"""F2 Control > Craft sub-mode with search results."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f2")
await pilot.pause()
control = app.query_one("#control", ControlScreen)
control.switch_mode("craft")
await pilot.pause()
# Trigger a search — uses DemoCraftClient
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
panel.post_message(CraftPanel.SearchRequested(""))
await asyncio.sleep(1.0)
path = f"{OUT_DIR}/tui-craft-search.svg"
app.save_screenshot(path)
print(f"Saved {path}")
async def _screenshot_craft_tracking():
"""F2 Control > Craft sub-mode actively tracking the Moon."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f2")
await pilot.pause()
control = app.query_one("#control", ControlScreen)
control.switch_mode("craft")
await pilot.pause()
# Start tracking the Moon (always above horizon in demo)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
panel.post_message(
CraftPanel.TrackRequested(
target_type="celestial",
target_id="moon",
name="Moon",
min_el=5.0,
)
)
await asyncio.sleep(2.5)
path = f"{OUT_DIR}/tui-craft-tracking.svg"
app.save_screenshot(path)
print(f"Saved {path}")
control._stop_craft_tracking()
await pilot.pause()
async def _screenshot_console():
"""F5 Console overlay."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await asyncio.sleep(1.0)
await pilot.press("f5")
await pilot.pause()
await asyncio.sleep(0.5)
path = f"{OUT_DIR}/tui-console.svg"
app.save_screenshot(path)
print(f"Saved {path}")
async def _screenshot_camera():
"""F6 Camera overlay."""
app = BirdcageApp()
app.demo_mode = True
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await asyncio.sleep(1.0)
await pilot.press("f6")
await pilot.pause()
await asyncio.sleep(0.5)
path = f"{OUT_DIR}/tui-camera.svg"
app.save_screenshot(path)
print(f"Saved {path}")
async def main():
await _screenshot_basic_tabs()
await _screenshot_craft_search()
await _screenshot_craft_tracking()
await _screenshot_console()
await _screenshot_camera()
print(f"\nAll screenshots saved to {OUT_DIR}/")
asyncio.run(main()) asyncio.run(main())

View File

@ -141,9 +141,14 @@ 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."""
from birdcage_tui.craft_client import CraftClient if self.demo_mode:
from birdcage_tui.demo import DemoCraftClient
client = CraftClient(base_url=self.craft_url) client = DemoCraftClient()
else:
from birdcage_tui.craft_client import CraftClient
client = CraftClient(base_url=self.craft_url)
try: try:
control = self.query_one("#control") control = self.query_one("#control")
if hasattr(control, "set_craft_client"): if hasattr(control, "set_craft_client"):

View File

@ -3,14 +3,21 @@
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.
No serial hardware required. No serial hardware required.
Also provides DemoCraftClient a duck-typed replacement for CraftClient that
returns synthetic satellite data with zero HTTP calls. Supports time-varying
LEO arcs so the tracking loop drives real pass events to the camera overlay.
""" """
import contextlib import contextlib
import datetime as _dt
import math import math
import random 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
class _DemoMenu(Enum): class _DemoMenu(Enum):
"""Simulated firmware submenu states.""" """Simulated firmware submenu states."""
@ -375,6 +382,10 @@ class DemoDevice:
timeout: float = 120, timeout: float = 120,
) -> list[dict[str, float]]: ) -> list[dict[str, float]]:
"""Simulate a firmware azscanwxp sweep with Gaussian signal peak.""" """Simulate a firmware azscanwxp sweep with Gaussian signal peak."""
# Snap EL to target so 2D scans compute correct per-row signal.
# Without this, move_motor(1, el) only sets _target_el — _el stays
# stale because the position interpolator never runs mid-sweep.
self._el = self._target_el
step_deg = step_cdeg / 100.0 step_deg = step_cdeg / 100.0
if step_deg <= 0: if step_deg <= 0:
step_deg = 1.0 step_deg = 1.0
@ -724,3 +735,216 @@ class DemoDevice:
if cmd == "reboot": if cmd == "reboot":
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>" return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
return f"Unknown command: {cmd}\nOS>" return f"Unknown command: {cmd}\nOS>"
# ------------------------------------------------------------------
# DemoCraftClient — offline Craft API replacement
# ------------------------------------------------------------------
# Canned satellite catalog for search results.
_DEMO_CATALOG: list[dict] = [
{
"name": "ISS (ZARYA)",
"type": "satellite",
"id": "25544",
"groups": ["stations"],
},
{
"name": "NOAA 19",
"type": "satellite",
"id": "33591",
"groups": ["weather"],
},
{
"name": "SO-50 (SAUDISAT 1C)",
"type": "satellite",
"id": "27607",
"groups": ["amateur"],
},
{
"name": "TEVEL-2",
"type": "satellite",
"id": "50988",
"groups": ["amateur"],
},
{
"name": "AO-91 (FOX-1B)",
"type": "satellite",
"id": "43017",
"groups": ["amateur"],
},
{
"name": "Moon",
"type": "celestial",
"id": "moon",
"groups": ["solar-system"],
},
{
"name": "Sun",
"type": "celestial",
"id": "sun",
"groups": ["solar-system"],
},
{
"name": "Jupiter",
"type": "celestial",
"id": "jupiter",
"groups": ["solar-system"],
},
]
# LEO arc parameters: (phase_offset_minutes, period_minutes, max_el)
_LEO_ARCS: dict[str, tuple[float, float, float]] = {
"25544": (0.0, 10.0, 55.0), # ISS — primary demo target
"33591": (3.0, 9.0, 42.0), # NOAA 19
"27607": (5.0, 8.5, 38.0), # SO-50
"50988": (7.0, 11.0, 48.0), # TEVEL-2
"43017": (2.0, 9.5, 35.0), # AO-91
}
def _leo_position(target_id: str, t: float) -> tuple[float, float]:
"""Compute time-varying AZ/EL for a simulated LEO satellite.
The arc traces: rise east (AZ ~90, EL 0) -> TCA (~180, max_el)
-> set west (AZ ~270, EL 0) over one period, then resets.
Returns (az, el) where el < 0 means below horizon.
"""
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
# 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
az = 90.0 + 180.0 * arc_phase
el = max_el * math.sin(math.pi * arc_phase)
return az, el
def _celestial_position(target_id: str, t: float) -> tuple[float, float]:
"""Slow-drift positions for celestial bodies."""
if target_id == "moon":
az = 145.0 + 0.5 * math.sin(t / 600.0) * (t / 60.0 % 10)
el = 32.0 + 3.0 * math.sin(t / 900.0)
return az, max(el, 5.0)
if target_id == "sun":
az = 210.0 + 0.3 * (t / 60.0 % 15)
el = 45.0 + 5.0 * math.sin(t / 1200.0)
return az, max(el, 10.0)
# Jupiter — nearly fixed
return 255.0, 28.0
class DemoCraftClient:
"""Offline replacement for CraftClient returning synthetic orbital data.
Duck-typed to match CraftClient's interface. No HTTP calls are made.
LEO satellites trace realistic arcs using time.monotonic() so the
tracking loop sees genuine AOS/TCA/LOS transitions.
"""
def __init__(self) -> None:
self._t0 = time.monotonic()
def health(self) -> bool:
return True
def search(self, query: str, limit: int = 20) -> list[SearchResult]:
q_lower = query.lower()
results = []
for entry in _DEMO_CATALOG:
if q_lower in entry["name"].lower():
results.append(
SearchResult(
name=entry["name"],
target_type=entry["type"],
target_id=entry["id"],
score=1.0,
groups=entry["groups"],
)
)
if len(results) >= limit:
break
return results
def get_passes(self, norad_id: int, hours: int = 24) -> list[PassPrediction]:
now = _dt.datetime.now(tz=_dt.UTC)
name = "Unknown"
for entry in _DEMO_CATALOG:
if entry["id"] == str(norad_id):
name = entry["name"]
break
arc_params = _LEO_ARCS.get(str(norad_id), (0.0, 10.0, 40.0))
_, period, max_el = arc_params
passes = []
for i in range(4):
aos = now + _dt.timedelta(minutes=30 * (i + 1))
tca = aos + _dt.timedelta(minutes=period / 2)
los = aos + _dt.timedelta(minutes=period)
duration = int(period * 60)
passes.append(
PassPrediction(
satellite_name=name,
norad_id=norad_id,
aos_time=aos.strftime("%Y-%m-%dT%H:%M:%S"),
aos_az=90.0 + random.uniform(-10, 10),
tca_time=tca.strftime("%Y-%m-%dT%H:%M:%S"),
tca_alt=max_el,
tca_az=180.0 + random.uniform(-15, 15),
los_time=los.strftime("%Y-%m-%dT%H:%M:%S"),
los_az=270.0 + random.uniform(-10, 10),
max_elevation=max_el,
duration_seconds=duration,
is_visible=i < 2,
)
)
return passes
def get_next_pass(self, norad_id: int) -> PassPrediction | None:
passes = self.get_passes(norad_id)
return passes[0] if passes else None
def get_visible_targets(self, min_alt: float = 0.0) -> list[TargetPosition]:
t = time.monotonic() - self._t0
targets = []
for entry in _DEMO_CATALOG:
tid = entry["id"]
ttype = entry["type"]
if ttype == "satellite" and tid in _LEO_ARCS:
az, el = _leo_position(tid, t)
elif ttype == "celestial":
az, el = _celestial_position(tid, t)
else:
continue
if el < min_alt:
continue
# Synthetic distance/range-rate for LEO targets
if ttype == "satellite":
dist = 400.0 + 200.0 * math.cos(math.pi * el / 90.0)
rr = -2.0 + 4.0 * math.sin(t / 120.0)
else:
dist = 384400.0 if tid == "moon" else 0.0
rr = 0.0
targets.append(
TargetPosition(
name=entry["name"],
target_type=ttype,
target_id=tid,
azimuth=round(az, 2),
altitude=round(el, 2),
distance_km=round(dist, 1),
range_rate=round(rr, 3),
)
)
return targets

View File

@ -0,0 +1,164 @@
"""Test DemoCraftClient — full Craft lifecycle in demo mode.
Unlike test_craft_mode.py which uses MagicMock, these tests run with the
real DemoCraftClient to verify the end-to-end demo experience: search,
pass predictions, and tracking with time-varying satellite positions.
No network calls are made.
"""
import asyncio
import pytest
from birdcage_tui.app import BirdcageApp
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()
@pytest.mark.asyncio
async def test_demo_search_returns_results():
"""DemoCraftClient search should populate the DataTable."""
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)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
panel.post_message(CraftPanel.SearchRequested("ISS"))
await asyncio.sleep(0.5)
from textual.widgets import DataTable
table = app.query_one("#craft-results-table", DataTable)
assert table.row_count >= 1
first_key = list(table.rows.keys())[0]
row = table.get_row(first_key)
assert "ISS" in row[0]
@pytest.mark.asyncio
async def test_demo_broad_search():
"""Empty query should return the full demo catalog."""
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)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
panel.post_message(CraftPanel.SearchRequested(""))
await asyncio.sleep(0.5)
from textual.widgets import DataTable
table = app.query_one("#craft-results-table", DataTable)
assert table.row_count == 8 # Full demo catalog
@pytest.mark.asyncio
async def test_demo_passes():
"""DemoCraftClient should return synthetic pass predictions."""
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)
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)
# Should contain max elevation of 55.0 degrees (ISS arc)
assert "55.0" in info.passes_text
@pytest.mark.asyncio
async def test_demo_tracking_drives_device():
"""DemoCraftClient tracking should 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)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
# Track the Moon — always above horizon in demo
panel.post_message(
CraftPanel.TrackRequested(
target_type="celestial",
target_id="moon",
name="Moon",
min_el=5.0,
)
)
await asyncio.sleep(2.5)
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
assert status.state == "TRACKING"
assert status.moves >= 1
# Device should have been commanded toward the Moon's position
assert app.device._target_az > 100.0 # Moon is around AZ 145
assert app.device._target_el > 20.0 # Moon is around EL 32
# Screenshot for visual verification
app.save_screenshot("/tmp/birdcage_demo_craft_tracking.svg")
# Stop tracking
control = app.query_one("#control", ControlScreen)
control._stop_craft_tracking()
await pilot.pause()
@pytest.mark.asyncio
async def test_demo_tracking_waiting_below_horizon():
"""Tracking a below-horizon LEO target shows WAITING."""
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)
panel = app.query_one("#ctrl-craft-panel", CraftPanel)
# SO-50 may be below horizon depending on timing.
# Use Jupiter at min_el=90 to guarantee WAITING.
panel.post_message(
CraftPanel.TrackRequested(
target_type="celestial",
target_id="jupiter",
name="Jupiter",
min_el=90.0, # Impossible — forces WAITING
)
)
await asyncio.sleep(2.0)
status = app.query_one("#craft-tracking-status", CraftTrackingStatus)
assert status.state == "WAITING"
# Cleanup
control = app.query_one("#control", ControlScreen)
control._stop_craft_tracking()
await pilot.pause()