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 -
-
- Networks: - 0 -
-
- Clients: - 0 -
-
- Hidden: - 0 -
-
- Open: - 0 -
-
- - IDLE -
-
-``` - -- [ ] **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 `` with 7 columns is replaced by `
`. The `updateNetworkTable()` / `createNetworkRow()` functions are rewritten to generate `
` elements with two visual lines (SSID + badges on top, signal bar + meta on bottom). - -The existing `selectedNetwork` variable is renamed to `selectedBssid` throughout `wifi.js`. - -- [ ] **Step 1: Replace table HTML in `index.html`** - -Find `.wifi-networks-panel` (~line 846). Replace the `
` and everything inside `.wifi-networks-panel` with: - -```html -
-
-
Discovered Networks
-
- - - - - -
-
- Sort: - - - -
-
-
-
-
-

No networks detected.
Start a scan to begin.

-
-
-
-
-``` - -- [ ] **Step 2: Replace table CSS with row CSS** - -In `static/css/index.css`, find the section starting with `/* WiFi Networks Panel (LEFT) */` (~line 3582). Remove all CSS rules for: -- `.wifi-networks-table`, `.wifi-networks-table thead`, `.wifi-networks-table th`, `.wifi-networks-table td`, `.wifi-networks-table th.sortable`, `.wifi-networks-table th:hover` -- `.col-essid`, `.col-bssid`, `.col-channel`, `.col-rssi`, `.col-security`, `.col-clients`, `.col-agent` -- `.wifi-network-row` (old table row) -- `.security-badge`, `.security-open`, `.security-wpa`, `.security-wpa3`, `.security-wep` -- `.signal-strong`, `.signal-medium`, `.signal-weak`, `.signal-very-weak` (old signal classes) -- `.agent-badge`, `.agent-local`, `.agent-remote` - -Add in their place: - -```css -/* WiFi Network List */ -.wifi-network-list { - display: flex; - flex-direction: column; -} - -.wifi-network-placeholder { - padding: 32px 16px; - text-align: center; - color: var(--text-dim); - font-size: 11px; - line-height: 1.6; -} - -/* Network rows */ -.network-row { - padding: 9px 14px; - border-bottom: 1px solid var(--bg-secondary); - border-left: 3px solid transparent; - cursor: pointer; - transition: background 0.15s; -} - -.network-row:hover { background: var(--bg-tertiary); } - -.network-row.selected { - background: rgba(74, 163, 255, 0.07); - border-left-color: var(--accent-cyan) !important; -} - -.network-row.threat-open { border-left-color: var(--accent-red); } -.network-row.threat-safe { border-left-color: var(--accent-green); } -.network-row.threat-hidden { border-left-color: var(--border-color); } - -.row-top { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 5px; -} - -.row-ssid { - font-size: 12px; - font-weight: 500; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 55%; -} - -.row-ssid.hidden-net { - color: var(--text-dim); - font-style: italic; -} - -.row-badges { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } - -.badge { - font-size: 9px; - padding: 2px 5px; - border-radius: 3px; - font-weight: 600; - letter-spacing: 0.5px; - border: 1px solid transparent; -} - -.badge.open { color: var(--accent-red); background: var(--accent-red-dim); border-color: var(--accent-red); } -.badge.wpa2 { color: var(--accent-green); background: var(--accent-green-dim); border-color: var(--accent-green); } -.badge.wpa3 { color: var(--accent-cyan); background: var(--accent-cyan-dim); border-color: var(--accent-cyan); } -.badge.wep { color: var(--accent-orange); background: var(--accent-amber-dim); border-color: var(--accent-orange); } -.badge.hidden-tag { color: var(--text-dim); background: transparent; border-color: var(--border-color); font-size: 8px; } - -.row-bottom { - display: flex; - align-items: center; - gap: 8px; -} - -.signal-bar-wrap { flex: 1; max-width: 130px; } - -.signal-track { - height: 4px; - background: var(--bg-elevated); - border-radius: 2px; - overflow: hidden; -} - -.signal-fill { height: 100%; border-radius: 2px; transition: width 0.3s; } -.signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); } -.signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); } -.signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); } - -.row-meta { - display: flex; - gap: 10px; - margin-left: auto; - color: var(--text-dim); - font-size: 10px; -} - -.row-rssi { color: var(--text-secondary); } - -/* Sort controls */ -.wifi-sort-controls { - display: flex; - align-items: center; - gap: 4px; -} - -.wifi-sort-label { - font-size: 9px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.wifi-sort-btn { - padding: 2px 6px; - font-size: 9px; - font-family: inherit; - background: none; - border: none; - color: var(--text-dim); - cursor: pointer; - transition: color 0.15s; -} - -.wifi-sort-btn:hover { color: var(--text-primary); } -.wifi-sort-btn.active { color: var(--accent-cyan); } -``` - -- [ ] **Step 3: Update `cacheDOM()` — swap table refs** - -In `wifi.js`'s `cacheDOM()` (~line 183), replace: -```js -networkTable: document.getElementById('wifiNetworkTable'), -networkTableBody: document.getElementById('wifiNetworkTableBody'), -``` -With: -```js -networkList: document.getElementById('wifiNetworkList'), -``` - -- [ ] **Step 4: Rename `selectedNetwork` → `selectedBssid` throughout `wifi.js`** - -Find all occurrences of `selectedNetwork` in `wifi.js` and rename to `selectedBssid`. There are ~6 occurrences (declaration, `selectNetwork()`, `closeDetail()`, `scheduleRender` block, `updateNetworkRow()`). Use a search-and-replace. - -- [ ] **Step 5: Rewrite `updateNetworkTable()` → `renderNetworks()`** - -Rename `updateNetworkTable()` to `renderNetworks()`. Replace the guard at the top: -```js -// old: -if (!elements.networkTableBody) return; -// new: -if (!elements.networkList) return; -``` - -Replace the empty-state block (the `if (filtered.length === 0)` section) with: -```js -if (filtered.length === 0) { - let message = networks.size > 0 - ? 'No networks match current filters' - : (isScanning ? 'Scanning for networks...' : 'Start scanning to discover networks'); - elements.networkList.innerHTML = `

${escapeHtml(message)}

`; - return; -} -``` - -Replace the render line: -```js -// old: -elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); -// new: -elements.networkList.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); -``` - -Add selected-state re-application after the render line: -```js -// Re-apply selected state after re-render -if (selectedBssid) { - const sel = elements.networkList.querySelector(`[data-bssid="${CSS.escape(selectedBssid)}"]`); - if (sel) sel.classList.add('selected'); -} -``` - -Update the `scheduleRender` call in the `requestAnimationFrame` block (line ~1091): -```js -// old: if (pendingRender.table) updateNetworkTable(); -if (pendingRender.table) renderNetworks(); -``` - -- [ ] **Step 6: Rewrite `createNetworkRow()` to produce div rows** - -Replace the entire `createNetworkRow(network)` function body: - -```js -function createNetworkRow(network) { - const rssi = network.rssi_current; - const security = network.security || 'Unknown'; - - // Badge class - const sec = security.toLowerCase(); - const badgeClass = sec === 'open' || sec === '' ? 'open' - : sec.includes('wpa3') ? 'wpa3' - : sec.includes('wpa') ? 'wpa2' - : sec.includes('wep') ? 'wep' - : 'wpa2'; - - // Threat class (left border) - const threatClass = badgeClass === 'open' ? 'threat-open' - : badgeClass === 'wpa2' || badgeClass === 'wpa3' ? 'threat-safe' - : 'threat-hidden'; - - // Signal bar width + class - const pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 80 * 100)) : 0; - const fillClass = rssi > -55 ? 'strong' : rssi > -70 ? 'medium' : 'weak'; - - const displayName = escapeHtml(network.display_name || network.essid || '[Hidden]'); - const isHidden = network.is_hidden; - const hiddenTag = isHidden ? 'HIDDEN' : ''; - - return ` -
-
- ${displayName} -
- ${escapeHtml(security)} - ${hiddenTag} -
-
-
-
-
-
-
-
-
- ch ${network.channel || '?'} - ${network.client_count || 0} ↔ - ${rssi != null ? rssi : '?'} -
-
-
- `; -} -``` - -- [ ] **Step 7: Update `initNetworkFilters()` to filter div rows** - -In `initNetworkFilters()` (~line 1024), find where filter buttons update the display. The existing logic toggles row visibility. Update it to operate on `.network-row` elements and their `data-band` / `data-security` attributes: - -```js -function applyFilter(filter) { - currentFilter = filter; - renderNetworks(); // simplest approach: just re-render with new filter -} -``` - -(The existing filter logic is already applied inside `updateNetworkTable()` / `renderNetworks()` via `currentFilter` — no DOM-level show/hide needed. If the existing `initNetworkFilters()` does DOM-level hiding, simplify it to just call `renderNetworks()` when `currentFilter` changes.) - -- [ ] **Step 8: Update `initSortControls()` to use `.wifi-sort-btn`** - -In `initSortControls()` (~line 1050), replace the existing `th[data-sort]` listener with: - -```js -function initSortControls() { - document.querySelectorAll('.wifi-sort-btn').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.sort; - if (currentSort.field === field) { - currentSort.order = currentSort.order === 'desc' ? 'asc' : 'desc'; - } else { - currentSort.field = field; - currentSort.order = 'desc'; - } - document.querySelectorAll('.wifi-sort-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - scheduleRender({ table: true }); - }); - }); -} -``` - -- [ ] **Step 9: Update `selectNetwork()` and `closeDetail()` to use div rows** - -In `selectNetwork()` (~line 1241), replace the row-selection query: -```js -// old: -elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(...) -// new: -elements.networkList?.querySelectorAll('.network-row').forEach(row => { - row.classList.toggle('selected', row.dataset.bssid === bssid); -}); -``` - -In `closeDetail()` (~line 1315), replace the row-deselection query similarly: -```js -elements.networkList?.querySelectorAll('.network-row').forEach(row => { - row.classList.remove('selected'); -}); -``` - -- [ ] **Step 10: Verify** - -```bash -sudo -E venv/bin/python intercept.py -``` -Open `http://localhost:5050/?mode=wifi`. Check: -- Network list shows styled div rows (two lines each, signal bars, coloured left borders) -- Filter buttons (All / 2.4G / 5G / Open / Hidden) still work -- Sort buttons (Signal / SSID / Ch) work -- Clicking a row highlights it (cyan left border + tinted background) -- Clicking a different row deselects the previous one - -- [ ] **Step 11: Commit** - -```bash -git add templates/index.html static/css/index.css static/js/modes/wifi.js -git commit -m "feat(wifi): replace table with styled div network rows" -``` - ---- - -## Task 3: Proximity radar — animated sweep - -**Files:** -- Modify: `templates/index.html` (~lines 882–900) -- Modify: `static/css/index.css` (~line 3787) -- Modify: `static/js/modes/wifi.js` (`initProximityRadar()`, `updateProximityRadar()`, `scheduleRender` block) - -### Context - -The existing radar uses the external `ProximityRadar` component (`static/js/components/proximity-radar.js`). We're replacing this with a hand-rolled inline SVG. The static SVG rings and the rotating sweep `` are placed directly in the template; JS only manages the network dot positions. - -- [ ] **Step 1: Replace radar HTML** - -In `index.html`, find `
` (~line 884). Replace the entire `
` contents with: - -```html -
-
Proximity Radar
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- 0 - Near -
-
- 0 - Mid -
-
- 0 - Far -
-
-
-``` - -- [ ] **Step 2: Add sweep animation CSS** - -In `static/css/index.css`, find `.wifi-radar-panel` (~line 3768). Add after its closing brace: - -```css -.wifi-radar-sweep { - transform-origin: 105px 105px; - animation: wifi-radar-rotate 3s linear infinite; -} - -@keyframes wifi-radar-rotate { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} -``` - -- [ ] **Step 3: Add `bssidToAngle()` helper and `renderRadar()` in `wifi.js`** - -In `wifi.js`, find the `// Proximity Radar` section (~line 1519). Replace `initProximityRadar()` and `updateProximityRadar()` entirely with: - -```js -// Simple hash of BSSID string → stable angle in radians -function bssidToAngle(bssid) { - let hash = 0; - for (let i = 0; i < bssid.length; i++) { - hash = (hash * 31 + bssid.charCodeAt(i)) & 0xffffffff; - } - return (hash >>> 0) / 0xffffffff * 2 * Math.PI; -} - -function renderRadar(networksList) { - const dotsGroup = document.getElementById('wifiRadarDots'); - if (!dotsGroup) return; - - const dots = []; - const zoneCounts = { immediate: 0, near: 0, far: 0 }; - - networksList.forEach(network => { - const rssi = network.rssi_current ?? -100; - const strength = Math.max(0, Math.min(1, (rssi + 100) / 80)); - const dotR = 5 + (1 - strength) * 90; // stronger = closer to centre - const angle = bssidToAngle(network.bssid); - const cx = 105 + dotR * Math.cos(angle); - const cy = 105 + dotR * Math.sin(angle); - - // Zone counts - if (dotR < 35) zoneCounts.immediate++; - else if (dotR < 70) zoneCounts.near++; - else zoneCounts.far++; - - // Visual radius by zone - const vr = dotR < 35 ? 6 : dotR < 70 ? 4.5 : 3; - - // Colour by security - const sec = (network.security || '').toLowerCase(); - const colour = sec === 'open' || sec === '' ? '#e25d5d' - : sec.includes('wpa') ? '#38c180' - : sec.includes('wep') ? '#d6a85e' - : '#484f58'; - - dots.push(` - - - `); - }); - - dotsGroup.innerHTML = dots.join(''); - - if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate; - if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near; - if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far; -} -``` - -- [ ] **Step 4: Wire `renderRadar()` into `scheduleRender()`** - -In `scheduleRender()`'s `requestAnimationFrame` callback (~line 1088), replace: -```js -if (pendingRender.radar) updateProximityRadar(); -``` -With: -```js -if (pendingRender.radar) renderRadar(Array.from(networks.values())); -``` - -Also update `init()` to remove the `initProximityRadar()` call (it's now a no-op since the SVG is static in the template). - -- [ ] **Step 5: Update `cacheDOM()` — remove old radar ref** - -Remove `channelBandTabs: document.getElementById('wifiChannelBandTabs')` (will be removed in Task 4 anyway). Remove `channelChart: document.getElementById('wifiChannelChart')`. Keep `proximityRadar` if referenced elsewhere, otherwise remove. - -- [ ] **Step 6: Verify** - -```bash -sudo -E venv/bin/python intercept.py -``` -Open `http://localhost:5050/?mode=wifi`. Check: -- Radar panel shows a slowly rotating sweep line with a trailing cyan arc -- Starting a scan populates coloured dots at stable positions (same BSSID always at same angle) -- Zone counts (Near / Mid / Far) update -- Open network dots are red; WPA2 dots are green - -- [ ] **Step 7: Commit** - -```bash -git add templates/index.html static/css/index.css static/js/modes/wifi.js -git commit -m "feat(wifi): animated SVG proximity radar with sweep rotation" -``` - ---- - -## Task 4: Channel heatmap + security ring - -**Files:** -- Modify: `templates/index.html` (~lines 902–938, the `.wifi-analysis-panel`) -- Modify: `static/css/index.css` (~lines 3824–3916, WiFi analysis panel CSS) -- Modify: `static/js/modes/wifi.js` (`cacheDOM()`, `initChannelChart()`, `updateChannelChart()`, `scheduleRender`) - -### Context - -The existing right panel has two sub-sections (`.wifi-channel-section` with a bar chart, `.wifi-security-section` with coloured dots). Both are replaced. The new panel has a shared header (`#wifiRightPanelTitle` + `#wifiDetailBackBtn`) used in Task 5, a `#wifiHeatmapView` with the heatmap grid and security ring, and a hidden `#wifiDetailView` placeholder (wired up in Task 5). - -- [ ] **Step 1: Replace right panel HTML** - -In `index.html`, find `
` (~line 902). Replace the entire block (up to the closing `
` of `.wifi-analysis-panel`) with: - -```html -
-
- Channel Heatmap - -
- - -
-
-
- 2.4 GHz · Last 0 scans -
-
- -
-
-
- Low -
- High -
-
-
- - - -
-
-
- - - -
-``` - -- [ ] **Step 2: Replace analysis panel CSS** - -In `static/css/index.css`, find `/* WiFi Analysis Panel (RIGHT) */` (~line 3824). Remove all existing rules for `.wifi-analysis-panel`, `.wifi-channel-section`, `.wifi-security-section`, `.wifi-channel-tabs`, `.channel-band-tab`, `.wifi-channel-chart`, `.wifi-security-stats`, `.wifi-security-item`, `.wifi-security-dot`, `.wifi-security-count`. - -Add in their place: - -```css -/* WiFi Analysis Panel */ -.wifi-analysis-panel { - display: flex; - flex-direction: column; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - overflow: hidden; -} - -.wifi-analysis-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - background: var(--bg-tertiary); - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; -} - -.wifi-analysis-panel-header .panel-title { - color: var(--accent-cyan); - font-size: 10px; - letter-spacing: 1.5px; - text-transform: uppercase; -} - -.wifi-detail-back-btn { - font-family: inherit; - font-size: 9px; - color: var(--text-dim); - background: none; - border: 1px solid var(--border-color); - border-radius: 3px; - padding: 2px 8px; - cursor: pointer; - transition: color 0.15s; -} - -.wifi-detail-back-btn:hover { color: var(--text-primary); } - -/* Heatmap */ -.wifi-heatmap-wrap { - padding: 10px 12px; - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - overflow: hidden; -} - -.wifi-heatmap-label { - font-size: 9px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 2px; -} - -.wifi-heatmap-ch-labels { - display: grid; - grid-template-columns: 26px repeat(11, 1fr); - gap: 2px; -} - -.wifi-heatmap-ch-label { - text-align: center; - font-size: 8px; - color: var(--text-dim); -} - -.wifi-heatmap-grid { - display: grid; - grid-template-columns: 26px repeat(11, 1fr); - gap: 2px; - flex: 1; - min-height: 0; -} - -.wifi-heatmap-time-label { - font-size: 8px; - color: var(--text-dim); - display: flex; - align-items: center; - justify-content: flex-end; - padding-right: 4px; -} - -.wifi-heatmap-cell { - border-radius: 2px; - min-height: 10px; -} - -.wifi-heatmap-empty { - grid-column: 1 / -1; - padding: 16px; - text-align: center; - color: var(--text-dim); - font-size: 10px; -} - -.wifi-heatmap-legend { - display: flex; - align-items: center; - gap: 6px; - font-size: 9px; - color: var(--text-dim); - margin-top: 2px; -} - -.wifi-heatmap-legend-grad { - flex: 1; - height: 6px; - border-radius: 3px; - background: linear-gradient(90deg, #0d1117 0%, #0d4a6e 30%, #0ea5e9 60%, #f97316 80%, #ef4444 100%); -} - -/* Security ring */ -.wifi-security-ring-wrap { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - background: var(--bg-secondary); - border-top: 1px solid var(--border-color); - flex-shrink: 0; -} - -.wifi-security-ring-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; -} - -.wifi-security-ring-item { - display: flex; - align-items: center; - gap: 6px; - font-size: 10px; -} - -.wifi-security-ring-dot { - width: 7px; - height: 7px; - border-radius: 1px; - flex-shrink: 0; -} - -.wifi-security-ring-name { color: var(--text-dim); flex: 1; } -.wifi-security-ring-count { color: var(--text-primary); font-weight: 600; } -``` - -- [ ] **Step 3: Update `cacheDOM()` — add heatmap elements, remove old chart/security refs** - -In `cacheDOM()`, remove: -```js -channelChart: document.getElementById('wifiChannelChart'), -channelBandTabs: document.getElementById('wifiChannelBandTabs'), -wpa3Count: document.getElementById('wpa3Count'), -wpa2Count: document.getElementById('wpa2Count'), -wepCount: document.getElementById('wepCount'), -``` -Add: -```js -heatmapGrid: document.getElementById('wifiHeatmapGrid'), -heatmapChLabels: document.getElementById('wifiHeatmapChLabels'), -heatmapCount: document.getElementById('wifiHeatmapCount'), -securityRingSvg: document.getElementById('wifiSecurityRingSvg'), -securityRingLegend: document.getElementById('wifiSecurityRingLegend'), -heatmapView: document.getElementById('wifiHeatmapView'), -detailView: document.getElementById('wifiDetailView'), -rightPanelTitle: document.getElementById('wifiRightPanelTitle'), -detailBackBtn: document.getElementById('wifiDetailBackBtn'), -``` - -- [ ] **Step 4: Add `channelHistory` state variable** - -Near the top of the module (where `networks`, `clients` etc. are declared), add: -```js -let channelHistory = []; // max 10 entries, each { timestamp, channels: {1:N,...,11:N} } -``` - -- [ ] **Step 5: Add heatmap initialisation (channel labels)** - -Replace `initChannelChart()` with: -```js -function initHeatmap() { - if (!elements.heatmapChLabels) return; - // Time-label placeholder + 11 channel labels - elements.heatmapChLabels.innerHTML = - '
' + - [1,2,3,4,5,6,7,8,9,10,11].map(ch => - `
${ch}
` - ).join(''); -} -``` -Call `initHeatmap()` from `init()` instead of `initChannelChart()`. - -- [ ] **Step 6: Add `renderHeatmap()` and `renderSecurityRing()` functions** - -Add after `initHeatmap()`: - -```js -function renderHeatmap() { - if (!elements.heatmapGrid) return; - - if (channelHistory.length === 0) { - elements.heatmapGrid.innerHTML = - '
Scan to populate channel history
'; - if (elements.heatmapCount) elements.heatmapCount.textContent = '0'; - return; - } - - if (elements.heatmapCount) elements.heatmapCount.textContent = channelHistory.length; - - // Find max value for colour scale - let maxVal = 1; - channelHistory.forEach(snap => { - Object.values(snap.channels).forEach(v => { if (v > maxVal) maxVal = v; }); - }); - - const rows = channelHistory.map((snap, i) => { - const timeLabel = i === 0 ? 'now' : ''; - const cells = [1,2,3,4,5,6,7,8,9,10,11].map(ch => { - const v = snap.channels[ch] || 0; - return `
`; - }); - return `
${timeLabel}
${cells.join('')}`; - }); - - elements.heatmapGrid.innerHTML = rows.join(''); -} - -function congestionColor(value, maxValue) { - if (value === 0 || maxValue === 0) return '#0d1117'; - const ratio = value / maxValue; - if (ratio < 0.05) return '#0d1117'; - if (ratio < 0.25) return `rgba(13,74,110,${(ratio * 4).toFixed(2)})`; - if (ratio < 0.5) return `rgba(14,165,233,${ratio.toFixed(2)})`; - if (ratio < 0.75) return `rgba(249,115,22,${ratio.toFixed(2)})`; - return `rgba(239,68,68,${ratio.toFixed(2)})`; -} - -function renderSecurityRing(networksList) { - const svg = elements.securityRingSvg; - const legend = elements.securityRingLegend; - if (!svg || !legend) return; - - const C = 2 * Math.PI * 15; // circumference ≈ 94.25 - const sec = networksList.reduce((acc, n) => { - const s = (n.security || '').toLowerCase(); - if (s.includes('wpa3')) acc.wpa3++; - else if (s.includes('wpa')) acc.wpa2++; - else if (s.includes('wep')) acc.wep++; - else acc.open++; - return acc; - }, { wpa2: 0, open: 0, wpa3: 0, wep: 0 }); - - const total = networksList.length || 1; - const segments = [ - { label: 'WPA2', color: '#38c180', count: sec.wpa2 }, - { label: 'Open', color: '#e25d5d', count: sec.open }, - { label: 'WPA3', color: '#4aa3ff', count: sec.wpa3 }, - { label: 'WEP', color: '#d6a85e', count: sec.wep }, - ]; - - let offset = 0; - const arcs = segments.map(seg => { - const arcLen = (seg.count / total) * C; - const arc = ``; - offset += arcLen; - return arc; - }); - - svg.innerHTML = arcs.join('') + - ''; - - legend.innerHTML = segments.map(seg => ` -
-
- ${seg.label} - ${seg.count} -
- `).join(''); -} -``` - -- [ ] **Step 7: Snapshot channel history in `renderNetworks()` and call render functions** - -At the top of `renderNetworks()` (just after the filter/sort), add the history snapshot: -```js -// Snapshot 2.4 GHz channel utilisation -const snapshot = { timestamp: Date.now(), channels: {} }; -for (let ch = 1; ch <= 11; ch++) snapshot.channels[ch] = 0; -Array.from(networks.values()) - .filter(n => n.band && n.band.startsWith('2.4')) - .forEach(n => { - const ch = parseInt(n.channel); - if (ch >= 1 && ch <= 11) snapshot.channels[ch]++; - }); -channelHistory.unshift(snapshot); -if (channelHistory.length > 10) channelHistory.pop(); -``` - -Then after `elements.networkList.innerHTML = ...`, add: -```js -renderHeatmap(); -renderSecurityRing(Array.from(networks.values())); -``` - -- [ ] **Step 8: Remove `updateChannelChart()` call from `scheduleRender()`** - -In `scheduleRender()`'s animation frame, replace: -```js -if (pendingRender.chart) updateChannelChart(); -``` -With nothing (delete this line). The heatmap is now updated from within `renderNetworks()`. - -- [ ] **Step 9: Verify** - -```bash -sudo -E venv/bin/python intercept.py -``` -Open `http://localhost:5050/?mode=wifi`. Check: -- Right panel shows "Channel Heatmap" header -- After scanning, heatmap grid populates with coloured cells (channels 6 and 11 should be hottest if neighbours visible) -- "Last N scans" count increments with each render -- Security ring shows proportional arcs for WPA2/Open/WPA3/WEP with counts - -- [ ] **Step 10: Commit** - -```bash -git add templates/index.html static/css/index.css static/js/modes/wifi.js -git commit -m "feat(wifi): channel heatmap and security ring chart" -``` - ---- - -## Task 5: Network detail panel (right panel takeover) - -**Files:** -- Modify: `templates/index.html` (remove `#wifiDetailDrawer`, populate `#wifiDetailView`) -- Modify: `static/css/index.css` (remove drawer CSS, add detail panel CSS) -- Modify: `static/js/modes/wifi.js` (`cacheDOM()`, `selectNetwork()`, `closeDetail()`, `updateDetailPanel()`) - -### Context - -The existing `#wifiDetailDrawer` slides up from the bottom. It is deleted. The new `#wifiDetailView` div (already added to the HTML in Task 4) is populated here. Clicking a network row hides `#wifiHeatmapView` and shows `#wifiDetailView` in the right panel. - -- [ ] **Step 1: Remove `#wifiDetailDrawer` from `index.html`** - -Find `
` (~line 940) and delete the entire block (from the opening div to its matching closing `
`, approximately 60 lines). - -- [ ] **Step 2: Populate `#wifiDetailView` in `index.html`** - -Find `