diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..41efe2c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,178 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking. + +## Common Commands + +### Docker (Primary) +```bash +# Build and run (basic profile) +docker compose --profile basic up -d + +# Build and run with ADS-B history (Postgres) +docker compose --profile history up -d + +# Rebuild after code changes +docker compose --profile basic up -d --build + +# Multi-arch build (amd64 + arm64 for RPi) +./build-multiarch.sh +``` + +### Local Setup (Alternative) +```bash +# First-time setup (interactive wizard with install profiles) +./setup.sh + +# Or headless full install +./setup.sh --non-interactive + +# Or install specific profiles +./setup.sh --profile=core,weather + +# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket) +sudo ./start.sh + +# Or for quick local dev (Flask dev server) +sudo -E venv/bin/python intercept.py + +# Other setup utilities +./setup.sh --health-check # Verify installation +./setup.sh --postgres-setup # Set up ADS-B history database +./setup.sh --menu # Force interactive menu +``` + +### Testing +```bash +# Run all tests +pytest + +# Run specific test file +pytest tests/test_bluetooth.py + +# Run with coverage +pytest --cov=routes --cov=utils + +# Run a specific test +pytest tests/test_bluetooth.py::test_function_name -v +``` + +### Linting and Formatting +```bash +# Lint with ruff +ruff check . + +# Auto-fix linting issues +ruff check --fix . + +# Format with black +black . + +# Type checking +mypy . +``` + +## Architecture + +### Entry Points +- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`. +- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server) +- `intercept.py` - Direct Flask dev server entry point (quick local development) +- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch + +### Route Blueprints (routes/) +Each signal type has its own Flask blueprint: +- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng +- `sensor.py` - 433MHz IoT sensors via rtl_433 +- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003) +- `acars.py` - Aircraft datalink messages via acarsdec +- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs) +- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs) +- `satellite.py` - Pass prediction using TLE data +- `sstv.py` - ISS SSTV image decoding via slowrx +- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump +- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring +- `aprs.py` - Amateur packet radio via direwolf +- `rtlamr.py` - Utility meter reading +- `meshtastic_routes.py` - Meshtastic LoRa mesh networking + +### Core Utilities (utils/) + +**SDR Abstraction Layer** (`utils/sdr/`): +- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay) +- Each type has a `CommandBuilder` for generating CLI commands + +**Bluetooth Module** (`utils/bluetooth/`): +- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ +- `aggregator.py` - Merges observations across time +- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag) +- `heuristics.py` - Behavioral analysis for device classification + +**TSCM (Counter-Surveillance)** (`utils/tscm/`): +- `baseline.py` - Snapshot "normal" RF environment +- `detector.py` - Compare current scan to baseline, flag anomalies +- `device_identity.py` - Track devices despite MAC randomization +- `correlation.py` - Cross-reference Bluetooth and WiFi observations + +**WiFi Utilities** (`utils/wifi/`): +- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS) +- `channel_analyzer.py` - Frequency band analysis + +**Weather Satellite** (`utils/weather_sat.py`): +- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT +- Subprocess management with stdout parsing, image watcher via rglob +- Pass prediction using skyfield TLE data + +**SSTV Decoder** (`utils/sstv.py`): +- ISS SSTV reception via slowrx with Doppler tracking +- Singleton pattern, image gallery with timestamped filenames + +### Key Patterns + +**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread. + +**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions. + +**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min). + +**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes. + +### External Tool Integrations + +| Tool | Purpose | Integration | +|------|---------|-------------| +| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng | +| multimon-ng | Pager decoding | Reads from rtl_fm stdout | +| rtl_433 | 433MHz sensors | JSON output parsing | +| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) | +| acarsdec | ACARS messages | Output parsing | +| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing | +| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable | +| slowrx | SSTV decoding | Subprocess with audio pipe | +| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT | +| AIS-catcher | AIS vessel tracking | JSON output parsing | +| direwolf | APRS | TNC modem for packet radio | + +### Frontend Structure +- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav) +- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`) +- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`) +- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()` + +### Docker +- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent) +- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B) +- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5) +- Data persisted via `./data:/app/data` volume mount + +### Configuration +- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`) +- Database: SQLite in `instance/` directory for settings, baselines, history + +## Testing Notes + +Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration. diff --git a/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md b/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md new file mode 100644 index 0000000..1b773a2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md @@ -0,0 +1,1037 @@ +# 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 new file mode 100644 index 0000000..e5d124f --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md @@ -0,0 +1,1480 @@ +# 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 `