From 3013eeee4c94e7d037c94147dcb53d63322c38e5 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 16 Feb 2026 11:49:39 -0700 Subject: [PATCH] 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) --- tui/scripts/take_screenshots.py | 134 +++++++++++++++++-- tui/src/birdcage_tui/app.py | 9 +- tui/src/birdcage_tui/demo.py | 224 ++++++++++++++++++++++++++++++++ tui/tests/test_demo_craft.py | 164 +++++++++++++++++++++++ 4 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 tui/tests/test_demo_craft.py diff --git a/tui/scripts/take_screenshots.py b/tui/scripts/take_screenshots.py index 05b1bf2..3898cd5 100644 --- a/tui/scripts/take_screenshots.py +++ b/tui/scripts/take_screenshots.py @@ -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()) diff --git a/tui/src/birdcage_tui/app.py b/tui/src/birdcage_tui/app.py index 945391b..9a428fe 100644 --- a/tui/src/birdcage_tui/app.py +++ b/tui/src/birdcage_tui/app.py @@ -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"): diff --git a/tui/src/birdcage_tui/demo.py b/tui/src/birdcage_tui/demo.py index 6ed45c6..d03e39f 100644 --- a/tui/src/birdcage_tui/demo.py +++ b/tui/src/birdcage_tui/demo.py @@ -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 diff --git a/tui/tests/test_demo_craft.py b/tui/tests/test_demo_craft.py new file mode 100644 index 0000000..9191d22 --- /dev/null +++ b/tui/tests/test_demo_craft.py @@ -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()