diff --git a/.gitignore b/.gitignore index a3e0534..4e40d58 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ data/subghz/captures/ # Local utility scripts reset-sdr.* -.superpowers/ +.superpowers/ +docs/superpowers/ diff --git a/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md b/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md deleted file mode 100644 index 1b773a2..0000000 --- a/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md +++ /dev/null @@ -1,1037 +0,0 @@ -# Satellite Telemetry Reliability Fixes — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix satellite tracking telemetry so elevation/azimuth/distance/visibility are always accurate and stable, and TLE data stays fresh automatically. - -**Architecture:** The SSE background tracker (server-side, location-unaware) is stripped of observer-relative data and made authoritative only for orbit position and ground track. The 5-second HTTP poll becomes the sole owner of observer-relative telemetry. A daily TLE refresh timer is added. Several smaller correctness bugs are fixed across the backend and frontend. - -**Tech Stack:** Python/Flask (backend tracker + routes), Skyfield (orbital mechanics), HTML/JS (dashboard frontend), pytest (tests) - ---- - -## File Map - -| File | What Changes | -|------|-------------| -| `routes/satellite.py` | Strip observer-relative fields from SSE tracker; fix altitude calc; add periodic TLE refresh; add `currentPos` fields to pass prediction | -| `templates/satellite_dashboard.html` | SSE handler ignores observer fields; telemetry polling owns elevation/az/dist/visible; fix `updateTelemetry` fallback; add METEOR-M2 to `WEATHER_SAT_KEYS`; fix abort controller; fix countdown | -| `tests/test_satellite.py` | New tests for tracker output shape, altitude calc, TLE refresh scheduling, pass currentPos fields | - ---- - -## Task 1: Strip observer-relative data from SSE tracker - -**Problem:** `_start_satellite_tracker` uses `DEFAULT_LATITUDE`/`DEFAULT_LONGITUDE` (both `0.0` by default). Every SSE message emits `visible: False` and az/el/dist based on the wrong location, overwriting correct data from the HTTP poll every second. - -**Fix:** Remove elevation, azimuth, distance, and visible from the SSE tracker output entirely. The SSE stream is server-wide and cannot know per-client observer location. The HTTP poll (`/satellite/position`) already handles observer-relative data correctly using the location from the POST body. - -**Files:** -- Modify: `routes/satellite.py:220-265` -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a failing test verifying tracker position dicts lack observer-relative fields** - -Add to `tests/test_satellite.py`: - -```python -def test_tracker_position_has_no_observer_fields(): - """SSE tracker positions must NOT include observer-relative fields. - - The tracker runs server-side with a fixed (potentially wrong) observer - location. Only the per-request /satellite/position endpoint, which - receives the client's actual location, should emit elevation/azimuth/ - distance/visible. - """ - from routes.satellite import _start_satellite_tracker - import threading, queue, time - - ISS_TLE = ( - 'ISS (ZARYA)', - '1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993', - '2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457', - ) - - sat_q = queue.Queue(maxsize=5) - - with patch('routes.satellite._tle_cache', {'ISS': ISS_TLE}), \ - patch('routes.satellite.get_tracked_satellites') as mock_tracked, \ - patch('routes.satellite.app') as mock_app: - mock_app.satellite_queue = sat_q - mock_tracked.return_value = [{ - 'name': 'ISS (ZARYA)', 'norad_id': 25544, - 'tle_line1': ISS_TLE[1], 'tle_line2': ISS_TLE[2], - }] - - t = threading.Thread(target=_start_satellite_tracker, daemon=True) - t.start() - try: - msg = sat_q.get(timeout=5) - finally: - # thread is daemon so it exits with test process - pass - - assert msg['type'] == 'positions' - pos = msg['positions'][0] - for forbidden in ('elevation', 'azimuth', 'distance', 'visible'): - assert forbidden not in pos, f"SSE tracker must not emit '{forbidden}'" - for required in ('lat', 'lon', 'altitude', 'satellite', 'norad_id'): - assert required in pos, f"SSE tracker must emit '{required}'" -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cd /Users/jsmith/Documents/Dev/intercept -pytest tests/test_satellite.py::test_tracker_position_has_no_observer_fields -v -``` - -Expected: FAIL — position dict currently contains `visible`. - -- [ ] **Step 3: Remove observer-relative fields from `_start_satellite_tracker`** - -In `routes/satellite.py`, replace the observer block inside `_start_satellite_tracker` (lines ~220–265). - -Remove these lines: -```python -obs_lat = DEFAULT_LATITUDE -obs_lon = DEFAULT_LONGITUDE -has_observer = (obs_lat != 0.0 or obs_lon != 0.0) -observer = wgs84.latlon(obs_lat, obs_lon) if has_observer else None -``` - -And remove the observer-relative block after `pos` is built: -```python -if has_observer and observer is not None: - diff = satellite - observer - topocentric = diff.at(now) - alt, az, dist = topocentric.altaz() - pos['elevation'] = float(alt.degrees) - pos['azimuth'] = float(az.degrees) - pos['distance'] = float(dist.km) - pos['visible'] = bool(alt.degrees > 0) -``` - -The `pos` dict should only contain `satellite`, `norad_id`, `lat`, `lon`, `altitude`, `groundTrack`. - -Also remove the `from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE` reference if it becomes unused (check if used elsewhere in the file first — it is imported at the top, keep the import if used elsewhere, just stop using it in the tracker). - -- [ ] **Step 4: Run test to verify it passes** - -```bash -pytest tests/test_satellite.py::test_tracker_position_has_no_observer_fields -v -``` - -Expected: PASS - -- [ ] **Step 5: Run full test suite** - -```bash -pytest tests/test_satellite.py -v -``` - -Expected: all existing tests still pass. - -- [ ] **Step 6: Commit** - -```bash -git add routes/satellite.py tests/test_satellite.py -git commit -m "fix(satellite): strip observer-relative fields from SSE tracker - -SSE runs server-wide with a fixed observer location (DEFAULT_LAT/LON -defaults to 0,0). Emitting elevation/azimuth/distance/visible from the -SSE stream produced wrong values that overwrote correct data from the -per-client HTTP poll every second. The HTTP poll (/satellite/position) -owns all observer-relative data; SSE now only emits lat/lon/altitude/ -groundTrack." -``` - ---- - -## Task 2: Fix frontend — SSE handler ignores observer-relative fields - -**Problem:** Even after Task 1, the frontend `handleLivePositions` passes `pos` directly to `applyTelemetryPosition`, which then calls `normalizeLivePosition` and merges all fields. The `updateVisible` flag also means SSE was setting the visible-count badge. We need the SSE path to only update lat/lon/altitude/groundTrack/map, leaving elevation/az/dist/visible for the HTTP poll. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `handleLivePositions` function (~line 1308) - -- [ ] **Step 1: Update `handleLivePositions` to strip observer fields before applying** - -Find `handleLivePositions` (around line 1308) and replace: - -```js -function handleLivePositions(positions) { - // Find the selected satellite by name or norad_id - const pos = findSelectedPosition(positions); - - // Update visible count from all positions - const visibleCount = positions.filter(p => p.visible).length; - const visEl = document.getElementById('statVisible'); - if (visEl) visEl.textContent = visibleCount; - - if (!pos) { - return; - } - applyTelemetryPosition( - { ...pos, visibleCount }, - { - updateVisible: true, - noradId: parseInt(pos.norad_id, 10) || selectedSatellite - } - ); -} -``` - -With: - -```js -function handleLivePositions(positions, source) { - // Find the selected satellite by name or norad_id - const pos = findSelectedPosition(positions); - - if (!pos) return; - - if (source === 'sse') { - // SSE is server-side and location-unaware: only update - // orbit position and ground track, never observer-relative fields. - const orbitOnly = { - satellite: pos.satellite, - norad_id: pos.norad_id, - lat: pos.lat, - lon: pos.lon, - altitude: pos.altitude, - groundTrack: pos.groundTrack, - track: pos.track, - }; - applyTelemetryPosition(orbitOnly, { - updateVisible: false, - noradId: parseInt(pos.norad_id, 10) || selectedSatellite, - }); - } else { - // HTTP poll: owns all observer-relative data including visible count - const visibleCount = positions.filter(p => p.visible).length; - const visEl = document.getElementById('statVisible'); - if (visEl) visEl.textContent = visibleCount; - applyTelemetryPosition( - { ...pos, visibleCount }, - { - updateVisible: true, - noradId: parseInt(pos.norad_id, 10) || selectedSatellite, - } - ); - } -} -``` - -- [ ] **Step 2: Thread `source` through the SSE call site** - -Find the SSE `onmessage` handler (~line 1288): -```js -satelliteSSE.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - if (msg.type === 'positions') handleLivePositions(msg.positions); - } catch (_) {} -}; -``` - -Change to: -```js -satelliteSSE.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - if (msg.type === 'positions') handleLivePositions(msg.positions, 'sse'); - } catch (_) {} -}; -``` - -- [ ] **Step 3: Thread `source` through the HTTP poll call site** - -Find in `fetchCurrentTelemetry` (~line 1397): -```js -handleLivePositions(data.positions); -``` - -Change to: -```js -handleLivePositions(data.positions, 'poll'); -``` - -- [ ] **Step 4: Manual smoke test** - -Open `/satellite/dashboard` in a browser. Confirm: -- Lat/Lon/Altitude update every ~1 second (from SSE) -- Elevation/Azimuth/Distance update every ~5 seconds (from HTTP poll) -- The visible-count badge doesn't reset to 0 every second - -- [ ] **Step 5: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "fix(satellite): SSE path only updates orbit position, not observer data - -The SSE stream no longer sets elevation/azimuth/distance/visible since -those fields were removed from the server-side tracker in the previous -commit. Adds a 'source' param to handleLivePositions so the SSE path -is gated to orbit-only fields, and the HTTP poll path owns all -observer-relative telemetry and visible-count badge." -``` - ---- - -## Task 3: Add periodic TLE auto-refresh (daily) - -**Problem:** `init_tle_auto_refresh()` fires once at startup (2s delay) then never again. TLEs are valid for roughly 1–2 weeks but degrade in accuracy after a few days, affecting pass prediction accuracy. - -**Fix:** Schedule a periodic 24-hour refresh using a repeating `threading.Timer` pattern. - -**Files:** -- Modify: `routes/satellite.py` — `init_tle_auto_refresh` function (~line 309) -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a failing test** - -Add to `tests/test_satellite.py`: - -```python -@patch('routes.satellite.refresh_tle_data', return_value=['ISS']) -@patch('routes.satellite._load_db_satellites_into_cache') -def test_tle_auto_refresh_schedules_repeat(mock_load_db, mock_refresh): - """init_tle_auto_refresh must schedule a follow-up refresh after the first run.""" - import threading - scheduled_delays = [] - original_timer = threading.Timer - - class CapturingTimer: - def __init__(self, delay, fn, *args, **kwargs): - scheduled_delays.append(delay) - # Don't actually start a real timer - self._fn = fn - def start(self): - pass # no-op - - with patch('routes.satellite.threading') as mock_threading: - mock_threading.Timer = CapturingTimer - mock_threading.Thread = threading.Thread # keep real Thread for tracker - - from routes.satellite import init_tle_auto_refresh - init_tle_auto_refresh() - - # First timer fires at 2s (startup delay) - assert any(d <= 5 for d in scheduled_delays), \ - "Expected a short startup delay timer" -``` - -- [ ] **Step 2: Run test to verify it passes already (baseline)** - -```bash -pytest tests/test_satellite.py::test_tle_auto_refresh_schedules_repeat -v -``` - -This test validates existing behaviour and should pass. It serves as a regression guard. - -- [ ] **Step 3: Add a 24-hour repeating refresh** - -In `routes/satellite.py`, replace `init_tle_auto_refresh`: - -```python -_TLE_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60 # 24 hours - - -def init_tle_auto_refresh(): - """Initialize TLE auto-refresh. Called by app.py after initialization.""" - import threading - - def _auto_refresh_tle(): - try: - _load_db_satellites_into_cache() - updated = refresh_tle_data() - if updated: - logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}") - except Exception as e: - logger.warning(f"Auto TLE refresh failed: {e}") - finally: - # Schedule next refresh regardless of success/failure - _schedule_next_tle_refresh() - - def _schedule_next_tle_refresh(delay: float = _TLE_REFRESH_INTERVAL_SECONDS): - t = threading.Timer(delay, _auto_refresh_tle) - t.daemon = True - t.start() - - # First refresh 2 seconds after startup (avoids blocking app init) - threading.Timer(2.0, _auto_refresh_tle).start() - logger.info("TLE auto-refresh scheduled (24h interval)") - - # Start live position tracker thread - tracker_thread = threading.Thread( - target=_start_satellite_tracker, - daemon=True, - name='satellite-tracker', - ) - tracker_thread.start() - logger.info("Satellite tracker thread launched") -``` - -- [ ] **Step 4: Write a test verifying the repeat schedule** - -Add to `tests/test_satellite.py`: - -```python -@patch('routes.satellite.refresh_tle_data', return_value=['ISS']) -@patch('routes.satellite._load_db_satellites_into_cache') -def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh): - """After the first TLE refresh, a 24-hour follow-up must be scheduled.""" - import threading - scheduled_delays = [] - - class CapturingTimer: - def __init__(self, delay, fn, *a, **kw): - scheduled_delays.append(delay) - self._fn = fn - self._ran = False - def start(self): - # Execute immediately so we can check the chained schedule - if not self._ran and scheduled_delays[0] <= 5: - self._ran = True - self._fn() # run the first (startup) timer inline - - with patch('routes.satellite.threading') as mock_threading: - mock_threading.Timer = CapturingTimer - mock_threading.Thread = threading.Thread - - # Re-import to pick up patched threading - import importlib, routes.satellite as sat_mod - sat_mod.init_tle_auto_refresh() - - # Should have scheduled startup delay AND a 24h follow-up - assert any(d >= 86000 for d in scheduled_delays), \ - f"Expected a ~24h repeat timer; got delays: {scheduled_delays}" -``` - -- [ ] **Step 5: Run new test** - -```bash -pytest tests/test_satellite.py::test_tle_auto_refresh_schedules_daily_repeat -v -``` - -Expected: PASS - -- [ ] **Step 6: Run full suite** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 7: Commit** - -```bash -git add routes/satellite.py tests/test_satellite.py -git commit -m "feat(satellite): add 24-hour periodic TLE auto-refresh - -TLE data was only refreshed once at startup. After each refresh, a new -24-hour timer is now scheduled (in the finally block so it fires even -on refresh failure). This keeps orbital elements fresh and pass -predictions accurate over multi-day deployments." -``` - ---- - -## Task 4: Fix `updateTelemetry` fallback — add proper currentPos fields - -**Problem:** When `latestLivePosition` is null (e.g. before first SSE/poll arrives), `updateTelemetry(pass)` falls back to `pass.currentPos`. But `currentPos` only has `lat` and `lon` (set in `predict_passes` at `satellite.py:509-517`). The fallback code reads `pos.alt`, `pos.el`, `pos.az`, `pos.dist` which are always undefined, so altitude/elevation/azimuth/distance always show `---` in this state. - -**Fix:** Populate `currentPos` with full position data (altitude, elevation, azimuth, distance, visible) in the `/satellite/predict` backend handler using Skyfield. - -**Files:** -- Modify: `routes/satellite.py` — `predict_passes` route handler (~line 508) -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a failing test** - -Add to `tests/test_satellite.py`: - -```python -@patch('routes.satellite._get_tracked_satellite_maps', return_value=({}, {})) -@patch('routes.satellite._get_timescale') -def test_predict_passes_currentpos_has_full_fields(mock_ts, mock_maps, client): - """currentPos in pass results must include altitude, elevation, azimuth, distance.""" - from skyfield.api import load - ts = load.timescale(builtin=True) - mock_ts.return_value = ts - - payload = { - 'latitude': 51.5074, - 'longitude': -0.1278, - 'hours': 48, - 'minEl': 5, - 'satellites': ['ISS'], - } - response = client.post('/satellite/predict', json=payload) - assert response.status_code == 200 - data = response.json - assert data['status'] == 'success' - if data['passes']: - cp = data['passes'][0].get('currentPos', {}) - for field in ('lat', 'lon', 'altitude', 'elevation', 'azimuth', 'distance'): - assert field in cp, f"currentPos missing field: {field}" -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -pytest tests/test_satellite.py::test_predict_passes_currentpos_has_full_fields -v -``` - -Expected: FAIL — `currentPos` currently only has `lat` and `lon`. - -- [ ] **Step 3: Enrich `currentPos` in the predict route** - -In `routes/satellite.py` inside `predict_passes`, find the block (~line 508-528): - -```python -for sat_name, norad_id, tle_data in resolved_satellites: - current_pos = None - try: - satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) - geo = satellite.at(t0) - sp = wgs84.subpoint(geo) - current_pos = { - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - } - except Exception: - pass -``` - -Replace with: - -```python -for sat_name, norad_id, tle_data in resolved_satellites: - current_pos = None - try: - satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) - geo = satellite.at(t0) - sp = wgs84.subpoint(geo) - subpoint_alt = float(sp.elevation.km) - current_pos = { - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - 'altitude': subpoint_alt, - } - # Add observer-relative data using the request's observer location - try: - diff = satellite - observer - topo = diff.at(t0) - alt_deg, az_deg, dist_km = topo.altaz() - current_pos['elevation'] = round(float(alt_deg.degrees), 1) - current_pos['azimuth'] = round(float(az_deg.degrees), 1) - current_pos['distance'] = round(float(dist_km.km), 1) - current_pos['visible'] = bool(alt_deg.degrees > 0) - except Exception: - pass - except Exception: - pass -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -pytest tests/test_satellite.py::test_predict_passes_currentpos_has_full_fields -v -``` - -Expected: PASS - -- [ ] **Step 5: Fix `updateTelemetry` fallback in the frontend to use correct field names** - -In `templates/satellite_dashboard.html`, find `updateTelemetry` (~line 2380): - -```js -function updateTelemetry(pass) { - if (latestLivePosition) { - applyTelemetryPosition(latestLivePosition); - return; - } - if (!pass || !pass.currentPos) { - clearTelemetry(); - return; - } - - const pos = pass.currentPos; - const telLat = document.getElementById('telLat'); - ... - if (telAlt && Number.isFinite(pos.alt)) telAlt.textContent = pos.alt.toFixed(0) + ' km'; - if (telEl && Number.isFinite(pos.el)) telEl.textContent = pos.el.toFixed(1) + '°'; - if (telAz && Number.isFinite(pos.az)) telAz.textContent = pos.az.toFixed(1) + '°'; - if (telDist && Number.isFinite(pos.dist)) telDist.textContent = pos.dist.toFixed(0) + ' km'; -} -``` - -Replace with a call to the existing `applyTelemetryPosition` to keep display logic in one place: - -```js -function updateTelemetry(pass) { - if (latestLivePosition) { - applyTelemetryPosition(latestLivePosition); - return; - } - if (!pass || !pass.currentPos) { - clearTelemetry(); - return; - } - // currentPos now contains full position data (lat, lon, altitude, - // elevation, azimuth, distance, visible) from the predict endpoint. - applyTelemetryPosition(pass.currentPos); -} -``` - -- [ ] **Step 6: Run full test suite** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 7: Commit** - -```bash -git add routes/satellite.py templates/satellite_dashboard.html tests/test_satellite.py -git commit -m "fix(satellite): populate currentPos with full telemetry in pass predictions - -Previously currentPos only had lat/lon so the updateTelemetry fallback -(used before first live position arrives) always showed '---' for -altitude/elevation/azimuth/distance. currentPos now includes all fields -computed from the request's observer location. updateTelemetry simplified -to delegate to applyTelemetryPosition." -``` - ---- - -## Task 5: Fix altitude calculation to use WGS84 subpoint elevation - -**Problem:** `_start_satellite_tracker` and `get_satellite_position` compute altitude as `geocentric.distance().km - 6371` (fixed spherical Earth radius). The `wgs84.subpoint()` call already returns a subpoint with an accurate `.elevation.km` property that accounts for Earth's oblateness. - -**Files:** -- Modify: `routes/satellite.py` — tracker loop (~line 248-255) and `/position` handler (~line 636-641) -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a test for altitude field presence and plausibility** - -Add to `tests/test_satellite.py`: - -```python -def test_satellite_altitude_is_plausible(): - """Satellite altitude must be in a plausible orbital range (100–50000 km).""" - from skyfield.api import EarthSatellite, wgs84, load - ISS_TLE = ( - 'ISS (ZARYA)', - '1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993', - '2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457', - ) - ts = load.timescale(builtin=True) - satellite = EarthSatellite(ISS_TLE[1], ISS_TLE[2], ISS_TLE[0], ts) - now = ts.now() - geocentric = satellite.at(now) - subpoint = wgs84.subpoint(geocentric) - altitude = float(subpoint.elevation.km) - assert 100 < altitude < 50000, f"Altitude {altitude} km is outside plausible range" -``` - -- [ ] **Step 2: Run test to verify it passes (validates approach)** - -```bash -pytest tests/test_satellite.py::test_satellite_altitude_is_plausible -v -``` - -Expected: PASS — this confirms `subpoint.elevation.km` works. - -- [ ] **Step 3: Update tracker loop altitude** - -In `routes/satellite.py` `_start_satellite_tracker`, find (~line 248): - -```python -pos = { - ... - 'altitude': float(geocentric.distance().km - 6371), - ... -} -``` - -Replace with: - -```python -pos = { - ... - 'altitude': float(subpoint.elevation.km), - ... -} -``` - -(`subpoint` is already computed on the line above as `subpoint = wgs84.subpoint(geocentric)`) - -- [ ] **Step 4: Update `/satellite/position` handler altitude** - -In `routes/satellite.py` `get_satellite_position`, find (~line 634): - -```python -pos_data = { - ... - 'altitude': float(geocentric.distance().km - 6371), - ... -} -``` - -Replace with (note: `subpoint` is computed just above as `subpoint = wgs84.subpoint(geocentric)`): - -```python -pos_data = { - ... - 'altitude': float(subpoint.elevation.km), - ... -} -``` - -- [ ] **Step 5: Update `currentPos` altitude in predict route (from Task 4)** - -In the `predict_passes` handler, the `current_pos` block now uses `subpoint.elevation.km` (already done in Task 4). Verify it matches the pattern. - -- [ ] **Step 6: Run full suite** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 7: Commit** - -```bash -git add routes/satellite.py tests/test_satellite.py -git commit -m "fix(satellite): use wgs84 subpoint elevation for altitude - -Replace geocentric.distance().km - 6371 (fixed spherical radius) with -wgs84.subpoint(geocentric).elevation.km in both the SSE tracker and -the /position endpoint. This accounts for Earth's oblateness and -matches the subpoint already being computed." -``` - ---- - -## Task 6: Add METEOR-M2 to weather satellite handoff keys - -**Problem:** `WEATHER_SAT_KEYS` only contains `'METEOR-M2-3'` and `'METEOR-M2-4'`. METEOR-M2 (NORAD 40069) is tracked and displayed but has no "→ Capture" button in the pass list. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `WEATHER_SAT_KEYS` constant (~line 2135) - -- [ ] **Step 1: Add METEOR-M2 to the set** - -Find: -```js -const WEATHER_SAT_KEYS = new Set([ - 'METEOR-M2-3', 'METEOR-M2-4' -]); -``` - -Replace with: -```js -const WEATHER_SAT_KEYS = new Set([ - 'METEOR-M2', 'METEOR-M2-3', 'METEOR-M2-4' -]); -``` - -- [ ] **Step 2: Verify in browser** - -Open `/satellite/dashboard`, calculate passes for METEOR-M2. Confirm a "→ Capture" button appears on each pass item. - -- [ ] **Step 3: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "fix(satellite): add METEOR-M2 to weather satellite handoff keys - -METEOR-M2 (NORAD 40069) is a weather satellite with LRPT downlink but -was missing from WEATHER_SAT_KEYS, so no capture button appeared in -the pass list. Adds it alongside M2-3 and M2-4." -``` - ---- - -## Task 7: Simplify `_telemetryAbortController` management - -**Problem:** The abort controller in `fetchCurrentTelemetry` has redundant null-checks in both the try and catch blocks. The pattern where `_telemetryAbortController` is checked against `controller` in the success path AND again in the catch path, combined with `_activeTelemetryRequestKey` deduplication, is overly complex and has a subtle issue: if `_telemetryAbortController?.signal?.aborted` is checked after it was already set to null, the check is always false. - -**Fix:** Simplify to a single active-request guard pattern: clear the controller in `finally`, not in both try and catch. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `fetchCurrentTelemetry` function (~line 1354) - -- [ ] **Step 1: Simplify `fetchCurrentTelemetry`** - -Find `fetchCurrentTelemetry` (~line 1354) and replace the function body: - -```js -async function fetchCurrentTelemetry(requestedSatellite = selectedSatellite, selectionToken = _satelliteSelectionRequestToken) { - const lat = parseFloat(document.getElementById('obsLat')?.value); - const lon = parseFloat(document.getElementById('obsLon')?.value); - if (!Number.isFinite(lat) || !Number.isFinite(lon) || !selectedSatellite) return; - - const requestKey = `telemetry:${requestedSatellite}:${lat.toFixed(3)}:${lon.toFixed(3)}`; - if (_activeTelemetryRequestKey === requestKey) return; // identical request already in flight - - // Cancel any in-flight request for a different satellite/location - if (_telemetryAbortController) { - _telemetryAbortController.abort(); - _telemetryAbortController = null; - } - - const controller = new AbortController(); - _telemetryAbortController = controller; - _activeTelemetryRequestKey = requestKey; - - try { - const timeoutId = setTimeout(() => controller.abort(), TELEMETRY_FETCH_TIMEOUT_MS); - const response = await fetch('/satellite/position', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - body: JSON.stringify({ - latitude: lat, - longitude: lon, - satellites: [requestedSatellite], - includeTrack: false - }) - }); - clearTimeout(timeoutId); - - if (!response.ok) return; - const contentType = response.headers.get('Content-Type') || ''; - if (!contentType.includes('application/json')) return; - const data = await response.json(); - if (data.status !== 'success' || !Array.isArray(data.positions)) return; - - // Discard if satellite or selection changed while request was in flight - if (selectionToken !== _satelliteSelectionRequestToken || requestedSatellite !== selectedSatellite) return; - - const pos = data.positions.find(p => parseInt(p.norad_id, 10) === requestedSatellite) || null; - if (!pos) return; - cacheLivePosition(requestedSatellite, pos); - handleLivePositions(data.positions, 'poll'); - - } catch (err) { - if (err?.name === 'AbortError') return; // expected on cancel/timeout - // unexpected error — log but don't crash - console.debug('Telemetry fetch error:', err); - } finally { - // Always release the controller slot so the next poll can run - if (_telemetryAbortController === controller) { - _telemetryAbortController = null; - } - if (_activeTelemetryRequestKey === requestKey) { - _activeTelemetryRequestKey = null; - } - } -} -``` - -- [ ] **Step 2: Manual smoke test** - -Open `/satellite/dashboard`. Switch between satellites rapidly. Confirm: -- Telemetry updates within ~5s of switching -- No stale data from the previous satellite appears after switching -- No console errors - -- [ ] **Step 3: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "refactor(satellite): simplify telemetry abort controller management - -The previous pattern had redundant null-checks in both try and catch, -and a subtle bug where checking signal.aborted after setting the -controller to null was always false. Consolidated to a single -active-request guard with cleanup in finally." -``` - ---- - -## Task 8: Fix ground track blocking the 1Hz tracker loop - -**Problem:** `_start_satellite_tracker` computes a 90-point orbit track on every cache miss inside the 1Hz loop. With many tracked satellites, cold-cache startup means multiple expensive Skyfield loops block the tracker for several seconds, causing the SSE stream to go silent until they complete. - -**Fix:** Compute ground tracks lazily in a thread pool so the main tracker loop stays snappy. If a track is not yet cached, emit the position without a ground track (the frontend already handles missing `groundTrack` gracefully). - -**Files:** -- Modify: `routes/satellite.py` — `_start_satellite_tracker` function (~line 266) - -- [ ] **Step 1: Refactor ground track computation to a thread pool** - -At the top of `routes/satellite.py`, add import (it's stdlib): - -```python -from concurrent.futures import ThreadPoolExecutor -``` - -Add a module-level thread pool (near the other module-level state, around line 50): - -```python -# Thread pool for background ground-track computation (non-blocking from tracker loop) -_track_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='sat-track') -_track_in_progress: set = set() # keys currently being computed -``` - -In `_start_satellite_tracker`, replace the ground track block (~lines 266-286): - -```python -# Ground track with caching (90 points, TTL 1800s) -cache_key_track = (sat_name, tle1[:20]) -cached = _track_cache.get(cache_key_track) -if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL: - pos['groundTrack'] = cached[0] -else: - track = [] - for minutes_offset in range(-45, 46, 1): - ... -``` - -With: - -```python -# Ground track with caching (90 points, TTL 1800s) -cache_key_track = (sat_name, tle1[:20]) -cached = _track_cache.get(cache_key_track) -if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL: - pos['groundTrack'] = cached[0] -elif cache_key_track not in _track_in_progress: - # Kick off computation in background — don't block the 1Hz loop - _track_in_progress.add(cache_key_track) - - def _compute_track(sat_obj, ts_ref, now_dt_ref, key, sat_name_ref): - try: - track = [] - for minutes_offset in range(-45, 46, 1): - t_point = ts_ref.utc(now_dt_ref + timedelta(minutes=minutes_offset)) - try: - geo = sat_obj.at(t_point) - sp = wgs84.subpoint(geo) - track.append({ - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - 'past': minutes_offset < 0, - }) - except Exception: - continue - _track_cache[key] = (track, time.time()) - except Exception: - pass - finally: - _track_in_progress.discard(key) - - _track_executor.submit(_compute_track, satellite, ts, now_dt, cache_key_track, sat_name) - # groundTrack omitted this tick — frontend retains previous value from SSE merge -``` - -- [ ] **Step 2: Run full test suite to confirm no regressions** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 3: Commit** - -```bash -git add routes/satellite.py -git commit -m "perf(satellite): compute ground tracks in thread pool, not inline - -Ground track computation (90 Skyfield points per satellite) was blocking -the 1Hz tracker loop on every cache miss. On cold start with multiple -tracked satellites this could stall the SSE stream for several seconds. -Tracks are now computed in a 2-worker ThreadPoolExecutor. The tracker -loop emits position without groundTrack on cache miss; clients retain -the previous track via SSE merge until the new one is ready." -``` - ---- - -## Task 9: Fix countdown when all passes are in the past - -**Problem:** `updateCountdown` falls back to `passes[0]` when no future pass is found. If `passes[0]` is in the past, the countdown displays 00:00:00:00 perpetually and the satellite name is misleading. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `updateCountdown` function (~line 2406) - -- [ ] **Step 1: Fix the countdown fallback** - -Find `updateCountdown` (~line 2406). Replace the section that handles the no-future-pass case: - -```js -if (!nextPass) nextPass = passes[0]; -``` - -With: - -```js -if (!nextPass) { - // All passes in window are in the past — show stale state - document.getElementById('countdownSat').textContent = 'NO UPCOMING PASSES'; - document.getElementById('countDays').textContent = '--'; - document.getElementById('countHours').textContent = '--'; - document.getElementById('countMins').textContent = '--'; - document.getElementById('countSecs').textContent = '--'; - ['countDays', 'countHours', 'countMins', 'countSecs'].forEach(id => { - document.getElementById(id)?.classList.remove('active'); - }); - return; -} -``` - -- [ ] **Step 2: Manual verification** - -To test, temporarily set `passes` to a list with a past timestamp in the browser console: -```js -passes = [{ satellite: 'ISS', startTimeISO: '2020-01-01T00:00:00', maxEl: 45, duration: 5 }]; -updateCountdown(); -``` -Confirm the countdown shows `NO UPCOMING PASSES` and `--` for all fields. - -- [ ] **Step 3: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "fix(satellite): show 'NO UPCOMING PASSES' when all passes are in the past - -updateCountdown fell back to passes[0] even when it was in the past, -showing 00:00:00:00 with a stale satellite name indefinitely. Now -displays a clear 'NO UPCOMING PASSES' state when no future pass exists -in the current 48-hour prediction window." -``` - ---- - -## Final: Run full test suite and verify - -- [ ] **Run all tests** - -```bash -cd /Users/jsmith/Documents/Dev/intercept -pytest tests/ -v --tb=short 2>&1 | tail -30 -``` - -Expected: all tests pass. - -- [ ] **Lint check** - -```bash -ruff check routes/satellite.py -``` - -Expected: no new errors. - -- [ ] **Manual end-to-end verification checklist** - -Open `/satellite/dashboard` and confirm: -1. Lat/Lon updates smoothly every ~1 second -2. Elevation/Azimuth/Distance update every ~5 seconds (not every 1 second) -3. Visible-count badge reflects client's actual location -4. Selecting a pass before first live data arrives shows altitude/el/az in telemetry panel -5. METEOR-M2 passes show "→ Capture" button -6. Switching satellites rapidly shows no stale data from previous satellite -7. Countdown shows `NO UPCOMING PASSES` rather than 00:00:00:00 when window is expired diff --git a/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md b/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md deleted file mode 100644 index e5d124f..0000000 --- a/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md +++ /dev/null @@ -1,1480 +0,0 @@ -# WiFi Scanner Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Redesign the WiFi scanner's main content area with richer network rows, an animated proximity radar sweep, a channel utilisation heatmap, a security ring chart, and a right-panel network detail view replacing the slide-up drawer. - -**Architecture:** All changes are pure frontend — HTML structure in `templates/index.html`, styles in `static/css/index.css`, and JS logic in `static/js/modes/wifi.js`. No backend routes are touched. The five tasks are independent enough to be committed separately and the UI remains functional after each one. - -**Tech Stack:** Vanilla JS (ES6 IIFE module pattern), CSS animations, inline SVG, Flask/Jinja2 templates. - ---- - -## Spec & reference - -- **Spec:** `docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md` -- **Start the app for manual verification:** - ```bash - sudo -E venv/bin/python intercept.py - # Open http://localhost:5050/?mode=wifi - ``` - -## File map - -| File | What changes | -|---|---| -| `templates/index.html` | All structural HTML changes (lines ~822–1005 for WiFi section) | -| `static/css/index.css` | WiFi section CSS (lines ~3515–3970+) | -| `static/js/modes/wifi.js` | `cacheDOM()`, `scheduleRender()`, `updateNetworkTable()` → `renderNetworks()`, `updateStats()`, `initProximityRadar()` → `renderRadar()`, `initChannelChart()` → `renderHeatmap()` + `renderSecurityRing()`, `selectNetwork()`, `closeDetail()`, `updateDetailPanel()` | - ---- - -## Task 1: Status bar — Open count + scan indicator - -**Files:** -- Modify: `templates/index.html` (lines ~824–841) -- Modify: `static/css/index.css` (lines ~3531–3570) -- Modify: `static/js/modes/wifi.js` (`cacheDOM()` ~line 183, `updateScanningState()` ~line 670, `updateStats()` ~line 1475) - -### Context - -The status bar currently has: Networks · Clients · Hidden · [scan status text]. We're adding an Open count (red) and replacing the text status with a pulsing dot indicator. - -The existing `#wifiScanStatus` element (line 837 of `index.html`) is replaced by `#wifiScanIndicator`. The existing `updateScanningState()` function (line ~670 of `wifi.js`) currently sets `.textContent` and `.className` on `elements.scanStatus` — it needs to toggle the dot's `display` instead. - -- [ ] **Step 1: Update status bar HTML** - -In `templates/index.html`, find the `wifi-status-bar` div (~line 824) and replace its contents: - -```html -
-``` - -- [ ] **Step 2: Add scan indicator CSS** - -In `static/css/index.css`, find the `.wifi-status-bar` block (~line 3531) and add after it: - -```css -.wifi-scan-indicator { - margin-left: auto; - display: flex; - align-items: center; - gap: 6px; - font-size: 10px; - color: var(--accent-cyan); - letter-spacing: 0.5px; -} - -.wifi-scan-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--accent-cyan); - display: none; - animation: wifi-scan-pulse 1.2s ease-in-out infinite; -} - -@keyframes wifi-scan-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(0.7); } -} -``` - -- [ ] **Step 3: Update JS — `cacheDOM()`** - -In `wifi.js`, find `cacheDOM()` (~line 183). Replace: -```js -// Status bar -scanStatus: document.getElementById('wifiScanStatus'), -``` -With: -```js -// Status bar -scanIndicator: document.getElementById('wifiScanIndicator'), -openCount: document.getElementById('wifiOpenCount'), -``` -(Keep `networkCount`, `clientCount`, `hiddenCount` unchanged. Remove the old `openCount: document.getElementById('openCount')` line in the security counts section.) - -- [ ] **Step 4: Update JS — `updateScanningState()`** - -Find `updateScanningState()` (~line 670). Replace the body that references `elements.scanStatus` with: - -```js -const dot = elements.scanIndicator?.querySelector('.wifi-scan-dot'); -const text = elements.scanIndicator?.querySelector('.wifi-scan-text'); -if (dot) dot.style.display = scanning ? 'inline-block' : 'none'; -if (text) text.textContent = scanning - ? `SCANNING (${scanMode === 'quick' ? 'Quick' : 'Deep'})` - : 'IDLE'; -``` - -- [ ] **Step 5: Update JS — `updateStats()` — add Open count, remove old security IDs** - -In `updateStats()` (~line 1475), find the block that updates `elements.wpa3Count`, `elements.wpa2Count`, `elements.wepCount`, `elements.openCount`. Replace the four element update lines with: - -```js -if (elements.openCount) elements.openCount.textContent = securityCounts.open; -``` - -(Remove the `wpa3Count`, `wpa2Count`, `wepCount` lines — those elements no longer exist. Keep the `securityCounts` calculation above unchanged, it's still needed by Task 4.) - -Also remove `wpa3Count`, `wpa2Count`, `wepCount` from `cacheDOM()` entirely. - -- [ ] **Step 6: Verify** - -```bash -sudo -E venv/bin/python intercept.py -``` -Open `http://localhost:5050/?mode=wifi`. Check: -- Status bar shows Networks / Clients / Hidden / Open (red) -- Clicking Quick Scan shows pulsing cyan dot + "SCANNING (Quick)" -- Stopping shows "IDLE" with no dot -- Open count increments as open networks are discovered - -- [ ] **Step 7: Commit** - -```bash -git add templates/index.html static/css/index.css static/js/modes/wifi.js -git commit -m "feat(wifi): enhanced status bar with open count and scan indicator" -``` - ---- - -## Task 2: Networks table → styled div list - -**Files:** -- Modify: `templates/index.html` (~lines 846–881) -- Modify: `static/css/index.css` (~lines 3582–3765) -- Modify: `static/js/modes/wifi.js` (`cacheDOM()`, `updateNetworkTable()`, `createNetworkRow()`, `initNetworkFilters()`, `initSortControls()`, `selectNetwork()`, `closeDetail()`) - -### Context - -The existing `