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 +
+
+ 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 `