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
from birdcage_tui.app import BirdcageApp
SCREENS = {
"f1": "dashboard",
"f2": "control",
"f3": "signal",
"f4": "system",
}
from birdcage_tui.screens.control import ControlScreen
from birdcage_tui.widgets.craft_panel import CraftPanel
OUT_DIR = "/home/rpm/claude/ham/satellite/winegard-travler/site/public/screenshots"
async def main():
for key, name in SCREENS.items():
async def _screenshot_basic_tabs():
"""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.demo_mode = True
@ -23,7 +33,6 @@ async def main():
await pilot.pause()
await pilot.press(key)
await pilot.pause()
# Let workers populate data.
await asyncio.sleep(1.5)
path = f"{OUT_DIR}/tui-{name}.svg"
@ -31,4 +40,107 @@ async def main():
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())

View File

@ -141,9 +141,14 @@ class BirdcageApp(App):
def _setup_craft_client(self) -> None:
"""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:
control = self.query_one("#control")
if hasattr(control, "set_craft_client"):

View File

@ -3,14 +3,21 @@
Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2
dish with motor movement, RSSI signal modeling, and canned firmware responses.
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 datetime as _dt
import math
import random
import time
from enum import Enum, auto
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition
class _DemoMenu(Enum):
"""Simulated firmware submenu states."""
@ -375,6 +382,10 @@ class DemoDevice:
timeout: float = 120,
) -> list[dict[str, float]]:
"""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
if step_deg <= 0:
step_deg = 1.0
@ -724,3 +735,216 @@ class DemoDevice:
if cmd == "reboot":
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
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()