From d01cb4b6f30392851e0e863139847b872b2232da Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 21:40:27 +0000 Subject: [PATCH 01/63] Add WiFi scanner redesign spec --- ...2026-03-26-wifi-scanner-redesign-design.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md diff --git a/docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md b/docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md new file mode 100644 index 0000000..0fc00ec --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md @@ -0,0 +1,128 @@ +# WiFi Scanner UI Redesign + +**Date:** 2026-03-26 +**Status:** Approved +**Scope:** Frontend only — CSS, JS, HTML. No backend changes. + +## Overview + +Redesign the WiFi scanner's main content area for better space utilisation, visual clarity, and polish. The three-panel layout (networks table / proximity radar / analysis) is kept but each panel is significantly enhanced. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Table row density | Slightly taller (2-line rows) | Adds visual richness without losing too many rows | +| Radar style | Rotating sweep with trailing glow arc | Most recognisable "radar" metaphor, eye-catching | +| Channel analysis | Heatmap (channels × time) | Shows congestion history, more useful than a snapshot bar chart | +| Row click behaviour | Right panel takeover (detail replaces heatmap) | Keeps spatial layout stable; no scroll disruption | + +## Component Designs + +### 1. Status Bar + +Enhanced version of the existing `wifi-status-bar`: + +- Existing fields: Networks · Clients · Hidden +- **New:** Open count in `var(--accent-red)` — instant threat count +- **New:** Scan indicator (pulsing dot + "SCANNING · ch N" text) floated right, updates to "IDLE" when stopped +- Minor spacing/typography polish + +### 2. Networks Table + +**Row structure (two visual lines per row):** + +``` +[left-border] SSID name / [Hidden] italic [SECURITY BADGE] [HIDDEN TAG] + [━━━━━━━━━▓░░░░░░░] signal bar ch 6 · 4 ↔ · −48 +``` + +**Left border colour coding:** +- `var(--accent-red)` — Open security +- `var(--accent-green)` — WPA2 / WPA3 +- `var(--border-color)` (dim) — Hidden network with unknown security + +**Signal bar:** gradient `green → amber → red` based on dBm value: +- Strong (> −55): green fill +- Medium (−55 to −70): green→amber gradient +- Weak (< −70): amber→red gradient + +**Security badge pills** (replacing current flat badges): +- Open: red pill with red border +- WPA2: green pill with green border +- WPA3: cyan pill with cyan border +- WPA/WPA2: amber pill + +**Hidden tag:** small dim pill "HIDDEN" next to security badge. + +**Row states:** +- Default: transparent background +- Hover: `var(--bg-tertiary)` +- Selected: `rgba(0,180,216,0.07)` + cyan left border override + +### 3. Proximity Radar + +**Animation:** Pure CSS — a `` element wrapping the sweep line + trailing arc rotates with `animation: radar-sweep 3s linear infinite`. No canvas required. + +**Sweep elements (inside rotating ``):** +- Trailing arc: a pie-slice `` filling ~60° behind the sweep line, filled `#00b4d8` at ~8% opacity +- Sweep line: `` from centre to edge, cyan, with a green highlight layer at low opacity + +**Network dots:** +- Positioned by signal strength (stronger = closer to centre) +- Sized: near=6px, mid=4.5px, far=3px radius +- Coloured by security: red=Open, green=WPA2/3, amber=WEP/weak, grey=hidden +- Each dot has a soft radial glow halo (second circle at ~1.5× radius, 10–15% opacity) +- Dots are outside the rotating group — they stay stationary + +**Zone summary row** below radar: Near / Mid / Far counts, coloured red / amber / green respectively. + +### 4. Right Panel — Channel Heatmap (default) + +**Layout:** Vertical stack: +1. Band label ("2.4 GHz · Last N scans") +2. Channel number labels (1–11) +3. Heatmap grid (JS-generated `
` cells) +4. Legend gradient bar (Low → High) +5. Security ring chart (always visible below heatmap) + +**Heatmap grid:** +- Rows = time steps (newest at top, oldest at bottom, ~10 rows) +- Columns = channels 1–11 for 2.4 GHz, with a tab toggle for 5 GHz +- Cell background colour maps congestion → colour: `#0d1117` (none) → dark blue → `#0ea5e9` (medium) → `#f97316` (high) → `#ef4444` (congested) +- Grid generated by `WiFiMode` using existing channel utilisation data + +**Security ring chart (below heatmap):** +- SVG ring using `stroke-dasharray` arcs +- Segments: WPA2 (green), Open (red), WPA3 (cyan), WEP (amber), proportional to counts +- Counts shown in a legend list to the right of the ring + +### 5. Right Panel — Network Detail (on row click) + +Replaces heatmap content in the same panel. Header changes from "Channel Heatmap" to "Network Detail" and a "← Back" button appears. + +**Detail panel contents:** +- SSID (large, bold) + BSSID (monospace, dim) +- Signal strength bar (same gradient as table rows, wider) +- 2×3 stat grid: Channel · Band · Security · Cipher · Clients · Vendor +- Action buttons: "⊕ Locate AP" (handoff to `WiFiLocate`) · "Close" (returns to heatmap) + +Closing via "← Back" or "Close" restores the heatmap and clears row selection. + +## File Changes + +| File | Change | +|---|---| +| `static/css/index.css` | Update/add WiFi section CSS (rows, radar, heatmap, detail panel, status bar) | +| `templates/index.html` | Update WiFi layout HTML structure | +| `static/js/modes/wifi.js` | Update `renderNetworks()`, add heatmap renderer, radar dot positioning, detail panel logic | + +No new files needed. No backend changes. + +## Out of Scope + +- WiFi locate mode (separate mode, untouched) +- Sidebar panel (signal source, scan settings, etc.) — untouched +- Attack options, handshake capture — untouched +- Mobile/responsive layout changes +- 5 GHz channel heatmap data (tab exists, data hookup is a follow-on) From 837090d15089163a88fe55e57d534baf2c6211b9 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 21:54:13 +0000 Subject: [PATCH 02/63] Finalise WiFi scanner redesign spec (reviewer approved) --- ...2026-03-26-wifi-scanner-redesign-design.md | 368 ++++++++++++++---- 1 file changed, 300 insertions(+), 68 deletions(-) diff --git a/docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md b/docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md index 0fc00ec..ca1d9f2 100644 --- a/docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md +++ b/docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md @@ -17,112 +17,344 @@ Redesign the WiFi scanner's main content area for better space utilisation, visu | Channel analysis | Heatmap (channels × time) | Shows congestion history, more useful than a snapshot bar chart | | Row click behaviour | Right panel takeover (detail replaces heatmap) | Keeps spatial layout stable; no scroll disruption | +## CSS Tokens + +Tokens confirmed in `index.css :root`: `--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-card`, `--border-color`, `--border-light`, `--text-primary`, `--text-secondary`, `--text-dim`, `--accent-cyan`, `--accent-cyan-dim`, `--accent-green`, `--accent-green-dim`, `--accent-red`, `--accent-red-dim`, `--accent-orange`, `--accent-amber`, `--accent-amber-dim`. + +**Not defined in `index.css :root`** — do not use: +- `--accent-yellow` (used in some existing WiFi rules but undefined — use `--accent-orange` instead) +- `--accent-cyan-rgb` (undefined — use the literal `rgba(74, 163, 255, 0.07)` for the selected row tint) + ## Component Designs ### 1. Status Bar Enhanced version of the existing `wifi-status-bar`: -- Existing fields: Networks · Clients · Hidden -- **New:** Open count in `var(--accent-red)` — instant threat count -- **New:** Scan indicator (pulsing dot + "SCANNING · ch N" text) floated right, updates to "IDLE" when stopped -- Minor spacing/typography polish +- Existing fields preserved: Networks · Clients · Hidden (IDs: `wifiNetworkCount`, `wifiClientCount`, `wifiHiddenCount`) +- **New — Open count:** Add `
Open0
` after the Hidden item. Populated by `renderNetworks()` counting networks where `security === 'Open'`. +- **Scan indicator — HTML:** Replace the existing `
` with: + ```html +
+ + IDLE +
+ ``` + CSS: `.wifi-scan-dot` is a 7×7px circle with `animation: wifi-pulse 1.2s ease-in-out infinite` (opacity 1→0.4→1, scale 1→0.7→1). The dot is hidden (`display:none`) when idle; shown when scanning. `.wifi-scan-indicator` is floated/pushed to `margin-left: auto`. +- `updateScanningState(scanning, scanMode)` — revised body: + ```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'; + ``` + `elements.scanIndicator` replaces `elements.scanStatus` in the `elements` map, pointing to `#wifiScanIndicator`. ### 2. Networks Table -**Row structure (two visual lines per row):** +**Structural change:** Remove `` (including `` and ``). Replace with `
`. Update `elements.networkTable → elements.networkList` and `elements.networkTableBody → elements.networkList` in the `elements` map in `wifi.js`. +**Sort controls:** The `th[data-sort]` click listener on the old table is removed. Add three small text-style sort buttons to the `.wifi-networks-header`: +```html +
+ Sort: + + + +
``` -[left-border] SSID name / [Hidden] italic [SECURITY BADGE] [HIDDEN TAG] - [━━━━━━━━━▓░░░░░░░] signal bar ch 6 · 4 ↔ · −48 +Clicking a button sets the existing `sortBy` state variable and calls `renderNetworks()`. Active button gets `.active` class (cyan text). + +**Filter buttons:** `#wifiNetworkFilters` / `.wifi-filter-btn` bar (All / 2.4G / 5G / Open / Hidden) is kept as-is in HTML. The JS `applyFilters()` function is adapted to operate on `
` elements using their `data-band` and `data-security` attributes (same logic, different element type). + +**Row HTML structure:** +```html +
+
+ SSID or [Hidden] BSSID +
+ LABEL + HIDDEN +
+
+
+
+
+
+
+
+
+ ch N + N ↔ + −N +
+
+
``` -**Left border colour coding:** -- `var(--accent-red)` — Open security -- `var(--accent-green)` — WPA2 / WPA3 -- `var(--border-color)` (dim) — Hidden network with unknown security +**Left border colour (CSS class on `.network-row`):** +- `.threat-open` → `border-left: 3px solid var(--accent-red)` +- `.threat-safe` → `border-left: 3px solid var(--accent-green)` (WPA2, WPA3) +- `.threat-hidden` → `border-left: 3px solid var(--border-color)` -**Signal bar:** gradient `green → amber → red` based on dBm value: -- Strong (> −55): green fill -- Medium (−55 to −70): green→amber gradient -- Weak (< −70): amber→red gradient +**Signal bar fill width:** `pct = Math.max(0, Math.min(100, (rssi + 100) / 80 * 100))` where −100 dBm → 0%, −20 dBm → 100%. -**Security badge pills** (replacing current flat badges): -- Open: red pill with red border -- WPA2: green pill with green border -- WPA3: cyan pill with cyan border -- WPA/WPA2: amber pill +**Signal fill classes:** +- `.strong` (rssi > −55): `background: linear-gradient(90deg, var(--accent-green), #88d49b)` +- `.medium` (−55 ≥ rssi > −70): `background: linear-gradient(90deg, var(--accent-green), var(--accent-orange))` +- `.weak` (rssi ≤ −70): `background: linear-gradient(90deg, var(--accent-orange), var(--accent-red))` -**Hidden tag:** small dim pill "HIDDEN" next to security badge. +**Security string → badge class mapping** (mirrors existing `securityClass` logic in `wifi.js`): +```js +function securityBadgeClass(security) { + const s = (security || '').toLowerCase(); + if (s === 'open' || s === '') return 'open'; + if (s.includes('wpa3')) return 'wpa3'; + if (s.includes('wpa')) return 'wpa2'; // covers WPA2, WPA/WPA2, WPA PSK, etc. + if (s.includes('wep')) return 'wep'; + return 'wpa2'; // fallback for unknown encrypted +} +``` + +**Security badge colours:** +- `.badge.open` — `var(--accent-red)` text + border, `var(--accent-red-dim)` background +- `.badge.wpa2` — `var(--accent-green)` text + border, `var(--accent-green-dim)` background +- `.badge.wpa3` — `var(--accent-cyan)` text + border, `var(--accent-cyan-dim)` background +- `.badge.wep` — `var(--accent-orange)` text + border, `var(--accent-amber-dim)` background +- `.badge.hidden-tag` — `var(--text-dim)` text, `var(--border-color)` border, transparent background + +**Radar dot colours (same semantic mapping):** +- Open: `var(--accent-red)` / `#e25d5d` +- WPA2 / WPA3: `var(--accent-green)` / `#38c180` +- WEP: `var(--accent-orange)` / `#d6a85e` +- Unknown / Hidden: `#484f58` + +**Selected state persistence across re-renders:** `WiFiMode` stores the selected BSSID in module-level `let selectedBssid = null`. After `renderNetworks()` rebuilds the list, if `selectedBssid` is set, find the matching row by `data-bssid` and add `.selected` to it; also refresh `#wifiDetailView` with updated data for that network. + +**Empty state:** When network list is empty, insert `

No networks detected.
Start a scan to begin.

`. **Row states:** -- Default: transparent background -- Hover: `var(--bg-tertiary)` -- Selected: `rgba(0,180,216,0.07)` + cyan left border override +- Hover: `background: var(--bg-tertiary)` +- Selected: `background: rgba(74, 163, 255, 0.07)` + `border-left-color: var(--accent-cyan)` (via `.selected` class, overrides threat colour) ### 3. Proximity Radar -**Animation:** Pure CSS — a `` element wrapping the sweep line + trailing arc rotates with `animation: radar-sweep 3s linear infinite`. No canvas required. +**Existing `#wifiProximityRadar` div** contents replaced with an inline SVG. `wifi.js` adds `renderRadar(networks)`. -**Sweep elements (inside rotating ``):** -- Trailing arc: a pie-slice `` filling ~60° behind the sweep line, filled `#00b4d8` at ~8% opacity -- Sweep line: `` from centre to edge, cyan, with a green highlight layer at low opacity +**SVG:** `width="100%" viewBox="0 0 210 210"`, centre `(105, 105)`. A `` is applied to the rotating group to prevent arc overflow. -**Network dots:** -- Positioned by signal strength (stronger = closer to centre) -- Sized: near=6px, mid=4.5px, far=3px radius -- Coloured by security: red=Open, green=WPA2/3, amber=WEP/weak, grey=hidden -- Each dot has a soft radial glow halo (second circle at ~1.5× radius, 10–15% opacity) -- Dots are outside the rotating group — they stay stationary +**Rings (static, outside rotating group):** +```html + + + + +``` -**Zone summary row** below radar: Near / Mid / Far counts, coloured red / amber / green respectively. +**Sweep animation — CSS:** +```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); } +} +``` -### 4. Right Panel — Channel Heatmap (default) +**Sweep group** ``: +- Trailing arc 60°: `` + _(endpoint derived: x = 105 + 100·sin(60°) ≈ 191.6, y = 105 − 100·cos(60°) + 100 = 155)_ +- Trailing arc 90°: `` + _(endpoint: x=205, y=105 — 90° clockwise from top)_ +- Sweep line: `` -**Layout:** Vertical stack: -1. Band label ("2.4 GHz · Last N scans") -2. Channel number labels (1–11) -3. Heatmap grid (JS-generated `
` cells) -4. Legend gradient bar (Low → High) -5. Security ring chart (always visible below heatmap) +**Network dots** (rendered as SVG `` elements outside the rotating group, replaced by `renderRadar(networks)`): +- Angle per dot: determined by `bssidToAngle(bssid)` — a simple hash of the BSSID string modulo 2π. This produces stable positions across re-renders for the same network. +- Radius from centre: `dotR = 5 + (1 - Math.max(0, Math.min(1, (rssi + 100) / 80))) * 90` — stronger signal → smaller radius +- Zone thresholds: dotR < 35 = Near, 35–70 = Mid, >70 = Far +- Visual radius: Near → 6, Mid → 4.5, Far → 3 (px) +- Each dot: a main `` + a glow halo `` at 1.5× visual radius, same fill colour, 12% opacity +- Colour: see Section 2 "Radar dot colours" -**Heatmap grid:** -- Rows = time steps (newest at top, oldest at bottom, ~10 rows) -- Columns = channels 1–11 for 2.4 GHz, with a tab toggle for 5 GHz -- Cell background colour maps congestion → colour: `#0d1117` (none) → dark blue → `#0ea5e9` (medium) → `#f97316` (high) → `#ef4444` (congested) -- Grid generated by `WiFiMode` using existing channel utilisation data +**Zone counts:** `renderRadar()` updates `#wifiZoneImmediate`, `#wifiZoneNear`, `#wifiZoneFar` (IDs unchanged). -**Security ring chart (below heatmap):** -- SVG ring using `stroke-dasharray` arcs -- Segments: WPA2 (green), Open (red), WPA3 (cyan), WEP (amber), proportional to counts -- Counts shown in a legend list to the right of the ring +### 4. Right Panel — Channel Heatmap + Security Ring -### 5. Right Panel — Network Detail (on row click) +**Removed elements:** The existing `.wifi-channel-section` (containing `.wifi-channel-tabs` and `#wifiChannelChart`), `.wifi-security-section`, and the IDs `openCount`, `wpa2Count`, `wpa3Count`, `wepCount` are all removed from the HTML. Any JS references to these IDs are also removed. -Replaces heatmap content in the same panel. Header changes from "Channel Heatmap" to "Network Detail" and a "← Back" button appears. +**New `.wifi-analysis-panel` inner HTML:** +```html +
+ Channel Heatmap + +
-**Detail panel contents:** -- SSID (large, bold) + BSSID (monospace, dim) -- Signal strength bar (same gradient as table rows, wider) -- 2×3 stat grid: Channel · Band · Security · Cipher · Clients · Vendor -- Action buttons: "⊕ Locate AP" (handoff to `WiFiLocate`) · "Close" (returns to heatmap) +
+
+
2.4 GHz · Last 0 scans
+
+ +
+
+
+ Low +
+ High +
+
+
+ + + + +
+ +
+
+
-Closing via "← Back" or "Close" restores the heatmap and clears row selection. + +``` + +**5 GHz heatmap:** Tab toggle removed. Heatmap always shows 2.4 GHz. **5 GHz networks are excluded from `channelHistory` snapshots** — only networks with `band === '2.4'` are counted when building each snapshot. + +**Heatmap data source:** Module-level `let channelHistory = []` (max 10 entries). Each `renderNetworks()` prepends `{ timestamp: Date.now(), channels: { 1:N, …, 11:N } }` built from 2.4 GHz networks only. Entries beyond 10 are dropped. `wifiHeatmapCount` span is updated with `channelHistory.length`. + +**Heatmap grid DOM structure:** `#wifiHeatmapGrid` is a CSS grid container (`display: grid; grid-template-columns: 26px repeat(11, 1fr); gap: 2px`). `renderHeatmap()` clears and rebuilds it on every call. For each of the up to 10 history entries (newest first), it creates one row of 12 elements: a time-label `
` (text "now" for index 0, empty for others) followed by 11 cell `
` elements whose `background` is set by `congestionColor()`. Total DOM nodes: up to 10 × 12 = 120 divs, fully rebuilt on each render call. + +**Cell colour — `congestionColor(value, maxValue)`:** +```js +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)})`; +} +``` +`maxValue` = the maximum cell value across the entire `channelHistory` array (for consistent colour scale). + +**Empty/loading state:** When `channelHistory.length === 0`, `#wifiHeatmapGrid` shows a single placeholder div: `
Scan to populate channel history
`. + +**Security ring — `renderSecurityRing(networks)`:** + +Circumference `C = 2 * Math.PI * 15 ≈ 94.25`. Each segment is a `` element with `stroke-dasharray="${arcLen} ${C - arcLen}"` and a `stroke-dashoffset` that positions it after the previous segments. The ring starts at the top by applying `transform="rotate(-90 24 24)"` to each arc. + +Standard SVG donut-segment technique — `dashoffset` for segment N = `-(sum of all preceding arcLengths)`: + +```js +const C = 2 * Math.PI * 15; // ≈ 94.25 +const segments = [ + { label: 'WPA2', color: 'var(--accent-green)', count: wpa2 }, + { label: 'Open', color: 'var(--accent-red)', count: open }, + { label: 'WPA3', color: 'var(--accent-cyan)', count: wpa3 }, + { label: 'WEP', color: 'var(--accent-orange)',count: wep }, +]; +const total = segments.reduce((s, seg) => s + seg.count, 0) || 1; +let offset = 0; +segments.forEach(seg => { + const arcLen = (seg.count / total) * C; + // + offset += arcLen; +}); +// Worked example: total=10, WPA2=7, Open=3: +// WPA2: arcLen=66.0, dasharray="66 28.2", dashoffset="0" +// Open: arcLen=28.2, dasharray="28.2 66", dashoffset="-66" +``` + +- Centre hole: `` injected last (on top) +- Legend rows injected into `#wifiSecurityRingLegend`: coloured square + name + count + +### 5. Right Panel — Network Detail + +**`#wifiDetailDrawer` deletion:** Delete the entire `
` block (~lines 940–1000 of `index.html`). Also remove all associated CSS from `index.css`: `.wifi-detail-drawer`, `.wifi-detail-drawer.open`, `.wifi-detail-header`, `.wifi-detail-title`, `.wifi-detail-essid`, `.wifi-detail-bssid`, `.wifi-detail-close`, `.wifi-detail-content`, `.wifi-detail-grid`, `.wifi-detail-stat`. In `wifi.js`, remove `detailDrawer` from the `elements` map and remove its usage in the existing `closeDetail()` (`detailDrawer.classList.remove('open')`) since `closeDetail()` is being replaced. + +**`#wifiDetailView` inner HTML:** +```html +
+
+
+
+
+ +
+
+ Signal + +
+
+
+
+
+ +
+
Channel
+
Band
+
Security
+
Cipher
+
Clients
+
First Seen
+
Vendor
+
+ + + + +
+ + + +
+
+``` + +**Signal bar fill width:** Same formula as table rows — `pct = Math.max(0, Math.min(100, (rssi + 100) / 80 * 100))`. + +**Show/hide:** +- `WiFiMode.selectNetwork(bssid)`: hides `#wifiHeatmapView`, shows `#wifiDetailView`, sets `#wifiRightPanelTitle` text to "Network Detail", shows `#wifiDetailBackBtn`, stores `selectedBssid = bssid` +- `WiFiMode.closeDetail()`: reverses — shows heatmap, hides detail, restores title to "Channel Heatmap", hides back button, sets `selectedBssid = null` + +**"← Back" vs "Close":** Both call `WiFiMode.closeDetail()`. "← Back" is in the panel header (always reachable at top); "Close" is at the bottom of the detail body for users who scrolled down. + +**`wifiClientCountBadge`** is preserved inside `#wifiDetailClientList` — no changes to the client list sub-panel. ## File Changes | File | Change | |---|---| -| `static/css/index.css` | Update/add WiFi section CSS (rows, radar, heatmap, detail panel, status bar) | -| `templates/index.html` | Update WiFi layout HTML structure | -| `static/js/modes/wifi.js` | Update `renderNetworks()`, add heatmap renderer, radar dot positioning, detail panel logic | - -No new files needed. No backend changes. +| `static/css/index.css` | Update WiFi section CSS (~line 3515): add `.wifi-scan-indicator`, `.wifi-scan-dot` + keyframes; replace table CSS with `.wifi-network-list`, `.network-row`, `.row-top`, `.row-bottom`, `.signal-bar-*`, `.badge.*`, `.wifi-sort-*`; add `.wifi-radar-sweep` + `@keyframes wifi-radar-rotate`; replace channel/security section CSS with `.wifi-heatmap-*`, `.wifi-security-ring-*`, `.wifi-analysis-panel-header`, `.wifi-detail-back-btn`; add `.wifi-detail-inner`, `.wifi-detail-*` | +| `templates/index.html` | WiFi section (~line 820): replace `
` with `
`, add sort controls to header, add `#wifiOpenCount` to status bar, replace `#wifiScanStatus` with `#wifiScanIndicator`, replace right panel contents with `#wifiHeatmapView` / `#wifiDetailView`, add `#wifiRightPanelTitle` + `#wifiDetailBackBtn` to panel header, inline radar SVG shell (static rings + empty dot group), remove `#wifiDetailDrawer` | +| `static/js/modes/wifi.js` | Update `elements` map (remove `networkTable`, `networkTableBody`, `detailDrawer`, `wpa3Count`, `wpa2Count`, `wepCount`, `openCount`; add `networkList`, `openCount` → `wifiOpenCount`, `scanIndicator`, `heatmapGrid`, `heatmapCount`, `securityRingSvg`, `securityRingLegend`, `heatmapView`, `detailView`, `rightPanelTitle`, `detailBackBtn`, `detailSignalFill`); update `renderNetworks()` (div rows, filter + sort, persistence of `selectedBssid`); update `updateScanningState()`; add `renderRadar(networks)`; add `renderHeatmap()`; add `renderSecurityRing(networks)`; add `selectNetwork(bssid)` / `closeDetail()`; remove `th[data-sort]` listener | +| `templates/partials/modes/wifi.html` | No changes — sidebar out of scope | ## Out of Scope -- WiFi locate mode (separate mode, untouched) -- Sidebar panel (signal source, scan settings, etc.) — untouched -- Attack options, handshake capture — untouched +- WiFi locate mode (separate) +- Sidebar panel (signal source, scan settings, attack options, handshake capture) - Mobile/responsive layout changes -- 5 GHz channel heatmap data (tab exists, data hookup is a follow-on) +- 5 GHz channel heatmap data population +- Any backend / route changes From 36399cf4aa4214b4f4f981a64c0ec1cee33542be Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:11:48 +0000 Subject: [PATCH 03/63] feat(wifi): enhanced status bar with open count and scan indicator Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 30 +++++++++++++++++++----------- static/js/modes/wifi.js | 24 ++++++++---------------- templates/index.html | 10 +++++++--- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 56954b2..8c9c72f 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3553,20 +3553,28 @@ header h1 .tagline { font-weight: 600; } -.wifi-status-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background: #666; +.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-status-indicator.idle { background: #666; } -.wifi-status-indicator.scanning { background: var(--accent-green); animation: pulse 1s infinite; } -.wifi-status-indicator.error { background: var(--accent-red); } +.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 pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } +@keyframes wifi-scan-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } } /* WiFi Main Content - 3 columns */ diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 787ea66..dbd15f4 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -190,7 +190,8 @@ const WiFiMode = (function() { scanModeDeep: document.getElementById('wifiScanModeDeep'), // Status bar - scanStatus: document.getElementById('wifiScanStatus'), + scanIndicator: document.getElementById('wifiScanIndicator'), + openCount: document.getElementById('wifiOpenCount'), networkCount: document.getElementById('wifiNetworkCount'), clientCount: document.getElementById('wifiClientCount'), hiddenCount: document.getElementById('wifiHiddenCount'), @@ -210,12 +211,6 @@ const WiFiMode = (function() { zoneNear: document.getElementById('wifiZoneNear'), zoneFar: document.getElementById('wifiZoneFar'), - // Security counts - wpa3Count: document.getElementById('wpa3Count'), - wpa2Count: document.getElementById('wpa2Count'), - wepCount: document.getElementById('wepCount'), - openCount: document.getElementById('openCount'), - // Detail drawer detailDrawer: document.getElementById('wifiDetailDrawer'), detailEssid: document.getElementById('wifiDetailEssid'), @@ -669,12 +664,12 @@ const WiFiMode = (function() { } // Update status - if (elements.scanStatus) { - elements.scanStatus.textContent = scanning - ? `Scanning (${scanMode === 'quick' ? 'Quick' : 'Deep'})...` - : 'Idle'; - elements.scanStatus.className = scanning ? 'status-scanning' : 'status-idle'; - } + 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'; } async function checkScanStatus() { @@ -1497,9 +1492,6 @@ const WiFiMode = (function() { else if (sec === 'open' || sec === '') securityCounts.open++; }); - if (elements.wpa3Count) elements.wpa3Count.textContent = securityCounts.wpa3; - if (elements.wpa2Count) elements.wpa2Count.textContent = securityCounts.wpa2; - if (elements.wepCount) elements.wepCount.textContent = securityCounts.wep; if (elements.openCount) elements.openCount.textContent = securityCounts.open; // Update zone summary diff --git a/templates/index.html b/templates/index.html index 6485d77..660312f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -834,9 +834,13 @@ Hidden: 0
-
- - Ready +
+ Open: + 0 +
+
+ + IDLE
From ea348b336082032ff3585ddf759e66a55c7b3ee1 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:18:51 +0000 Subject: [PATCH 04/63] feat(wifi): replace table with styled div network rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 7-column
network list with flex div rows featuring two-line layout (SSID + security badges on top, signal bar + meta on bottom), coloured left-border threat indicators, and new sort controls. Renames selectedNetwork → selectedBssid and updateNetworkTable → renderNetworks throughout wifi.js. Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 181 +++++++++++++++++++++++----------------- static/js/modes/wifi.js | 170 ++++++++++++++++++------------------- templates/index.html | 33 +++----- 3 files changed, 197 insertions(+), 187 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 8c9c72f..b0ac50d 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3650,112 +3650,137 @@ header h1 .tagline { overscroll-behavior: contain; } -.wifi-networks-table { - width: 100%; - border-collapse: collapse; - font-size: 11px; +/* WiFi Network List */ +.wifi-network-list { + display: flex; + flex-direction: column; } -.wifi-networks-table thead { - position: sticky; - top: 0; - background: var(--bg-tertiary); - z-index: 1; -} - -.wifi-networks-table th { - padding: 8px 10px; - text-align: left; +.wifi-network-placeholder { + padding: 32px 16px; + text-align: center; color: var(--text-dim); - font-weight: 500; - border-bottom: 1px solid var(--border-color); - white-space: nowrap; - cursor: pointer; + font-size: 11px; + line-height: 1.6; } -.wifi-networks-table th:hover { - color: var(--accent-cyan); -} - -.wifi-networks-table th.sortable::after { - content: ' \2195'; - opacity: 0.3; -} - -.wifi-networks-table td { - padding: 8px 10px; - border-bottom: 1px solid var(--border-color); - vertical-align: middle; -} - -.wifi-network-row { +/* 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; } -.wifi-network-row:hover { - background: rgba(0, 255, 255, 0.05); +.network-row:hover { background: var(--bg-tertiary); } + +.network-row.selected { + background: rgba(74, 163, 255, 0.07); + border-left-color: var(--accent-cyan) !important; } -.wifi-network-row.selected { - background: rgba(0, 255, 255, 0.1); - border-left: 2px solid var(--accent-cyan); +.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; } -.wifi-network-row .essid { +.row-ssid { + font-size: 12px; font-weight: 500; color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 55%; } -.wifi-network-row .badge { - display: inline-block; - padding: 2px 5px; +.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; - margin-left: 6px; -} - -.wifi-network-row .badge-hidden { - background: rgba(255, 165, 0, 0.2); - color: var(--accent-orange); -} - -.wifi-network-row .badge-new { - background: rgba(0, 255, 0, 0.2); - color: var(--accent-green); -} - -.wifi-network-row .rssi-value { - font-family: monospace; font-weight: 600; + letter-spacing: 0.5px; + border: 1px solid transparent; } -.wifi-network-row .rssi-value.signal-strong { color: var(--accent-green); } -.wifi-network-row .rssi-value.signal-medium { color: var(--accent-yellow); } -.wifi-network-row .rssi-value.signal-weak { color: var(--accent-orange); } -.wifi-network-row .rssi-value.signal-very-weak { color: var(--accent-red); } +.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; } -.wifi-network-row .security-badge { - display: inline-block; +.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; - border-radius: 3px; -} - -.wifi-network-row .security-badge.security-wpa3 { background: rgba(0, 255, 0, 0.15); color: var(--accent-green); } -.wifi-network-row .security-badge.security-wpa { background: rgba(0, 255, 255, 0.15); color: var(--accent-cyan); } -.wifi-network-row .security-badge.security-wep { background: rgba(255, 165, 0, 0.15); color: var(--accent-orange); } -.wifi-network-row .security-badge.security-open { background: rgba(255, 0, 0, 0.15); color: var(--accent-red); } - -.wifi-network-placeholder td { - text-align: center; - padding: 40px 20px; -} - -.wifi-network-placeholder .placeholder-text { + 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); } + .app-collection-state-row td { text-align: center; padding: 0; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index dbd15f4..c9489d3 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -121,7 +121,7 @@ const WiFiMode = (function() { let recommendations = []; // UI state - let selectedNetwork = null; + let selectedBssid = null; let currentFilter = 'all'; let currentSort = { field: 'rssi', order: 'desc' }; let renderFramePending = false; @@ -196,9 +196,8 @@ const WiFiMode = (function() { clientCount: document.getElementById('wifiClientCount'), hiddenCount: document.getElementById('wifiHiddenCount'), - // Network table - networkTable: document.getElementById('wifiNetworkTable'), - networkTableBody: document.getElementById('wifiNetworkTableBody'), + // Network list + networkList: document.getElementById('wifiNetworkList'), networkFilters: document.getElementById('wifiNetworkFilters'), // Visualizations @@ -972,7 +971,7 @@ const WiFiMode = (function() { stats: true, radar: true, chart: true, - detail: selectedNetwork === network.bssid, + detail: selectedBssid === network.bssid, }); if (onNetworkUpdate) onNetworkUpdate(network); @@ -1004,7 +1003,7 @@ const WiFiMode = (function() { network.display_name = `${revealedSsid} (revealed)`; scheduleRender({ table: true, - detail: selectedNetwork === bssid, + detail: selectedBssid === bssid, }); // Show notification @@ -1039,34 +1038,27 @@ const WiFiMode = (function() { }); } - updateNetworkTable(); + renderNetworks(); } function initSortControls() { if (listenersBound.sort) return; - if (!elements.networkTable) return; - elements.networkTable.addEventListener('click', (e) => { - const th = e.target.closest('th[data-sort]'); - if (th) { - const field = th.dataset.sort; + 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'; } - updateNetworkTable(); - } + document.querySelectorAll('.wifi-sort-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + scheduleRender({ table: true }); + }); }); - if (elements.networkTableBody) { - elements.networkTableBody.addEventListener('click', (e) => { - const row = e.target.closest('tr[data-bssid]'); - if (!row) return; - selectNetwork(row.dataset.bssid); - }); - } listenersBound.sort = true; } @@ -1083,12 +1075,12 @@ const WiFiMode = (function() { requestAnimationFrame(() => { renderFramePending = false; - if (pendingRender.table) updateNetworkTable(); + if (pendingRender.table) renderNetworks(); if (pendingRender.stats) updateStats(); if (pendingRender.radar) updateProximityRadar(); if (pendingRender.chart) updateChannelChart(); - if (pendingRender.detail && selectedNetwork) { - updateDetailPanel(selectedNetwork, { refreshClients: false }); + if (pendingRender.detail && selectedBssid) { + updateDetailPanel(selectedBssid, { refreshClients: false }); } pendingRender.table = false; @@ -1099,8 +1091,8 @@ const WiFiMode = (function() { }); } - function updateNetworkTable() { - if (!elements.networkTableBody) return; + function renderNetworks() { + if (!elements.networkList) return; // Filter networks let filtered = Array.from(networks.values()); @@ -1157,87 +1149,89 @@ const WiFiMode = (function() { }); if (filtered.length === 0) { - let message = 'Start scanning to discover networks'; - let type = 'empty'; - if (isScanning) { - message = 'Scanning for networks...'; - type = 'loading'; - } else if (networks.size > 0) { - message = 'No networks match current filters'; - } - if (typeof renderCollectionState === 'function') { - renderCollectionState(elements.networkTableBody, { - type, - message, - columns: 7, - }); - } else { - elements.networkTableBody.innerHTML = ``; - } + 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; } - // Render table - elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + // Render list + elements.networkList.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + + // 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'); + } } function createNetworkRow(network) { const rssi = network.rssi_current; const security = network.security || 'Unknown'; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - const securityClass = security === 'Open' ? 'security-open' : - security === 'WEP' ? 'security-wep' : - security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; + // 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'; - const hiddenBadge = network.is_hidden ? 'Hidden' : ''; - const newBadge = network.is_new ? 'New' : ''; + // Threat class (left border) + const threatClass = badgeClass === 'open' ? 'threat-open' + : badgeClass === 'wpa2' || badgeClass === 'wpa3' ? 'threat-safe' + : 'threat-hidden'; - // Agent source badge - const agentName = network._agent || 'Local'; - const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote'; + // 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 : '?'} +
+
+
`; } function updateNetworkRow(network) { scheduleRender({ table: true, - detail: selectedNetwork === network.bssid, + detail: selectedBssid === network.bssid, }); } function selectNetwork(bssid) { - selectedNetwork = bssid; + selectedBssid = bssid; // Update row selection - elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(row => { + elements.networkList?.querySelectorAll('.network-row').forEach(row => { row.classList.toggle('selected', row.dataset.bssid === bssid); }); @@ -1308,11 +1302,11 @@ const WiFiMode = (function() { } function closeDetail() { - selectedNetwork = null; + selectedBssid = null; if (elements.detailDrawer) { elements.detailDrawer.classList.remove('open'); } - elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(row => { + elements.networkList?.querySelectorAll('.network-row').forEach(row => { row.classList.remove('selected'); }); } @@ -1431,7 +1425,7 @@ const WiFiMode = (function() { function updateClientInList(client) { // Check if this client belongs to the currently selected network - if (!selectedNetwork || client.associated_bssid !== selectedNetwork) { + if (!selectedBssid || client.associated_bssid !== selectedBssid) { return; } @@ -1459,7 +1453,7 @@ const WiFiMode = (function() { } } else { // New client for this network - re-fetch the full list - fetchClientsForNetwork(selectedNetwork); + fetchClientsForNetwork(selectedBssid); } } @@ -1738,7 +1732,7 @@ const WiFiMode = (function() { probeRequests = []; channelStats = []; recommendations = []; - if (selectedNetwork) { + if (selectedBssid) { closeDetail(); } scheduleRender({ table: true, stats: true, radar: true, chart: true }); @@ -1788,7 +1782,7 @@ const WiFiMode = (function() { } }); clientsToRemove.forEach(mac => clients.delete(mac)); - if (selectedNetwork && !networks.has(selectedNetwork)) { + if (selectedBssid && !networks.has(selectedBssid)) { closeDetail(); } scheduleRender({ table: true, stats: true, radar: true, chart: true }); diff --git a/templates/index.html b/templates/index.html index 660312f..8a89130 100644 --- a/templates/index.html +++ b/templates/index.html @@ -846,7 +846,7 @@
- +
Discovered Networks
@@ -857,28 +857,19 @@
+
+ Sort: + + + +
-
${escapeHtml(message)}
- ${escapeHtml(network.display_name || network.essid || '[Hidden]')} - ${hiddenBadge}${newBadge} - ${escapeHtml(network.bssid)}${network.channel || '-'} - ${rssi != null ? rssi : '-'} - - ${escapeHtml(security)} - ${network.client_count || 0} - ${escapeHtml(agentName)} -
- - - - - - - - - - - - - - - - -
SSIDBSSIDChSignalSecurityClientsSource
-
Start scanning to discover networks
-
+
+
+

No networks detected.
Start a scan to begin.

+
+
From 0dbcb175c0d3db42473ab76b5f32848c88e58672 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:23:40 +0000 Subject: [PATCH 05/63] fix(wifi): XSS fix for onclick handler, unknown security badge, null rssi handling Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 1 + static/js/modes/wifi.js | 3724 +++++++++++++++++++-------------------- 2 files changed, 1863 insertions(+), 1862 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index b0ac50d..d84de78 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3722,6 +3722,7 @@ header h1 .tagline { .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; } +.badge.unknown { color: var(--text-dim); background: transparent; border-color: var(--border-color); } .row-bottom { display: flex; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index c9489d3..631249a 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -1,1862 +1,1862 @@ -/** - * WiFi Mode Controller (v2) - * - * Unified WiFi scanning with dual-mode architecture: - * - Quick Scan: System tools without monitor mode - * - Deep Scan: airodump-ng with monitor mode - * - * Features: - * - Proximity radar visualization - * - Channel utilization analysis - * - Hidden SSID correlation - * - Real-time SSE streaming - */ - -const WiFiMode = (function() { - 'use strict'; - - // ========================================================================== - // Configuration - // ========================================================================== - - const CONFIG = { - apiBase: '/wifi/v2', - pollInterval: 5000, - keepaliveTimeout: 30000, - maxNetworks: 500, - maxClients: 500, - maxProbes: 1000, - }; - - // ========================================================================== - // Agent Support - // ========================================================================== - - /** - * Get the API base URL, routing through agent proxy if agent is selected. - */ - function getApiBase() { - if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') { - return `/controller/agents/${currentAgent}/wifi/v2`; - } - return CONFIG.apiBase; - } - - /** - * Get the current agent name for tagging data. - */ - function getCurrentAgentName() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return 'Local'; - } - if (typeof agents !== 'undefined') { - const agent = agents.find(a => a.id == currentAgent); - return agent ? agent.name : `Agent ${currentAgent}`; - } - return `Agent ${currentAgent}`; - } - - /** - * Check for agent mode conflicts before starting WiFi scan. - */ - async function checkAgentConflicts() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return true; - } - if (typeof checkAgentModeConflict === 'function') { - return await checkAgentModeConflict('wifi'); - } - return true; - } - - function getChannelPresetList(preset) { - switch (preset) { - case '2.4-common': - return '1,6,11'; - case '2.4-all': - return '1,2,3,4,5,6,7,8,9,10,11,12,13'; - case '5-low': - return '36,40,44,48'; - case '5-mid': - return '52,56,60,64'; - case '5-high': - return '149,153,157,161,165'; - default: - return ''; - } - } - - function buildChannelConfig() { - const preset = document.getElementById('wifiChannelPreset')?.value || ''; - const listInput = document.getElementById('wifiChannelList')?.value || ''; - const singleInput = document.getElementById('wifiChannel')?.value || ''; - - const listValue = listInput.trim(); - const presetValue = getChannelPresetList(preset); - - const channels = listValue || presetValue || ''; - const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); - - return { - channels: channels || null, - channel: Number.isFinite(channel) ? channel : null, - }; - } - - // ========================================================================== - // State - // ========================================================================== - - let isScanning = false; - let scanMode = 'quick'; // 'quick' or 'deep' - let eventSource = null; - let pollTimer = null; - let agentPollTimer = null; - - // Data stores - let networks = new Map(); // bssid -> network - let clients = new Map(); // mac -> client - let probeRequests = []; - let channelStats = []; - let recommendations = []; - - // UI state - let selectedBssid = null; - let currentFilter = 'all'; - let currentSort = { field: 'rssi', order: 'desc' }; - let renderFramePending = false; - const pendingRender = { - table: false, - stats: false, - radar: false, - chart: false, - detail: false, - }; - const listenersBound = { - scanTabs: false, - filters: false, - sort: false, - }; - - // Agent state - let showAllAgentsMode = false; // Show combined results from all agents - let lastAgentId = null; // Track agent switches - - // Capabilities - let capabilities = null; - - // Callbacks for external integration - let onNetworkUpdate = null; - let onClientUpdate = null; - let onProbeRequest = null; - - // ========================================================================== - // Initialization - // ========================================================================== - - function init() { - console.log('[WiFiMode] Initializing...'); - - // Cache DOM elements - cacheDOM(); - - // Check capabilities - checkCapabilities(); - - // Initialize components - initScanModeTabs(); - initNetworkFilters(); - initSortControls(); - initProximityRadar(); - initChannelChart(); - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - - // Check if already scanning - checkScanStatus(); - - console.log('[WiFiMode] Initialized'); - } - - // DOM element cache - let elements = {}; - - function cacheDOM() { - elements = { - // Scan controls - quickScanBtn: document.getElementById('wifiQuickScanBtn'), - deepScanBtn: document.getElementById('wifiDeepScanBtn'), - stopScanBtn: document.getElementById('wifiStopScanBtn'), - scanModeQuick: document.getElementById('wifiScanModeQuick'), - scanModeDeep: document.getElementById('wifiScanModeDeep'), - - // Status bar - scanIndicator: document.getElementById('wifiScanIndicator'), - openCount: document.getElementById('wifiOpenCount'), - networkCount: document.getElementById('wifiNetworkCount'), - clientCount: document.getElementById('wifiClientCount'), - hiddenCount: document.getElementById('wifiHiddenCount'), - - // Network list - networkList: document.getElementById('wifiNetworkList'), - networkFilters: document.getElementById('wifiNetworkFilters'), - - // Visualizations - proximityRadar: document.getElementById('wifiProximityRadar'), - channelChart: document.getElementById('wifiChannelChart'), - channelBandTabs: document.getElementById('wifiChannelBandTabs'), - - // Zone summary - zoneImmediate: document.getElementById('wifiZoneImmediate'), - zoneNear: document.getElementById('wifiZoneNear'), - zoneFar: document.getElementById('wifiZoneFar'), - - // Detail drawer - detailDrawer: document.getElementById('wifiDetailDrawer'), - detailEssid: document.getElementById('wifiDetailEssid'), - detailBssid: document.getElementById('wifiDetailBssid'), - detailRssi: document.getElementById('wifiDetailRssi'), - detailChannel: document.getElementById('wifiDetailChannel'), - detailBand: document.getElementById('wifiDetailBand'), - detailSecurity: document.getElementById('wifiDetailSecurity'), - detailCipher: document.getElementById('wifiDetailCipher'), - detailVendor: document.getElementById('wifiDetailVendor'), - detailClients: document.getElementById('wifiDetailClients'), - detailFirstSeen: document.getElementById('wifiDetailFirstSeen'), - detailClientList: document.getElementById('wifiDetailClientList'), - - // Interface select - interfaceSelect: document.getElementById('wifiInterfaceSelect'), - - // Capability status - capabilityStatus: document.getElementById('wifiCapabilityStatus'), - - // Export buttons - exportCsvBtn: document.getElementById('wifiExportCsv'), - exportJsonBtn: document.getElementById('wifiExportJson'), - }; - } - - // ========================================================================== - // Capabilities - // ========================================================================== - - async function checkCapabilities() { - const capBtn = document.getElementById('wifiQuickScanBtn'); - if (capBtn) capBtn.classList.add('btn-loading'); - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - let response; - - if (isAgentMode) { - // Fetch capabilities from agent via controller proxy - response = await fetch(`/controller/agents/${currentAgent}?refresh=true`); - if (!response.ok) throw new Error('Failed to fetch agent capabilities'); - - const data = await response.json(); - // Extract WiFi capabilities from agent data - if (data.agent && data.agent.capabilities) { - const agentCaps = data.agent.capabilities; - const agentInterfaces = data.agent.interfaces || {}; - - // Build WiFi-compatible capabilities object - capabilities = { - can_quick_scan: agentCaps.wifi || false, - can_deep_scan: agentCaps.wifi || false, - interfaces: (agentInterfaces.wifi_interfaces || []).map(iface => ({ - name: iface.name || iface, - supports_monitor: iface.supports_monitor !== false - })), - default_interface: agentInterfaces.default_wifi || null, - preferred_quick_tool: 'agent', - issues: [] - }; - console.log('[WiFiMode] Agent capabilities:', capabilities); - } else { - throw new Error('Agent does not support WiFi mode'); - } - } else { - // Local capabilities - response = await fetch(`${CONFIG.apiBase}/capabilities`); - if (!response.ok) throw new Error('Failed to fetch capabilities'); - capabilities = await response.json(); - console.log('[WiFiMode] Local capabilities:', capabilities); - } - - updateCapabilityUI(); - populateInterfaceSelect(); - } catch (error) { - console.error('[WiFiMode] Capability check failed:', error); - showCapabilityError('Failed to check WiFi capabilities'); - } finally { - if (capBtn) capBtn.classList.remove('btn-loading'); - } - } - - function updateCapabilityUI() { - if (!capabilities || !elements.capabilityStatus) return; - - let html = ''; - - if (!capabilities.can_quick_scan && !capabilities.can_deep_scan) { - html = ` -
- WiFi scanning not available -
    - ${capabilities.issues.map(i => `
  • ${escapeHtml(i)}
  • `).join('')} -
-
- `; - } else { - // Show available modes - const modes = []; - if (capabilities.can_quick_scan) modes.push('Quick Scan'); - if (capabilities.can_deep_scan) modes.push('Deep Scan'); - - html = ` -
- Available modes: ${modes.join(', ')} - ${capabilities.preferred_quick_tool ? ` (using ${capabilities.preferred_quick_tool})` : ''} -
- `; - - if (capabilities.issues.length > 0) { - html += ` -
- ${capabilities.issues.join('. ')} -
- `; - } - } - - elements.capabilityStatus.innerHTML = html; - elements.capabilityStatus.style.display = html ? 'block' : 'none'; - - // Enable/disable scan buttons based on capabilities - if (elements.quickScanBtn) { - elements.quickScanBtn.disabled = !capabilities.can_quick_scan; - } - if (elements.deepScanBtn) { - elements.deepScanBtn.disabled = !capabilities.can_deep_scan; - } - } - - function showCapabilityError(message) { - if (!elements.capabilityStatus) return; - - elements.capabilityStatus.innerHTML = ` -
${escapeHtml(message)}
- `; - elements.capabilityStatus.style.display = 'block'; - } - - function populateInterfaceSelect() { - if (!elements.interfaceSelect || !capabilities) return; - - elements.interfaceSelect.innerHTML = ''; - - if (capabilities.interfaces.length === 0) { - elements.interfaceSelect.innerHTML = ''; - return; - } - - capabilities.interfaces.forEach(iface => { - const option = document.createElement('option'); - option.value = iface.name; - option.textContent = `${iface.name}${iface.supports_monitor ? ' (monitor capable)' : ''}`; - elements.interfaceSelect.appendChild(option); - }); - - // Select default - if (capabilities.default_interface) { - elements.interfaceSelect.value = capabilities.default_interface; - } - } - - // ========================================================================== - // Scan Mode Tabs - // ========================================================================== - - function initScanModeTabs() { - if (listenersBound.scanTabs) return; - if (elements.scanModeQuick) { - elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); - } - if (elements.scanModeDeep) { - elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); - } - // Arrow key navigation between tabs - const tabContainer = document.querySelector('.wifi-scan-mode-tabs'); - if (tabContainer) { - tabContainer.addEventListener('keydown', (e) => { - const tabs = Array.from(tabContainer.querySelectorAll('[role="tab"]')); - const idx = tabs.indexOf(document.activeElement); - if (idx === -1) return; - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - e.preventDefault(); - const next = tabs[(idx + 1) % tabs.length]; - next.focus(); - next.click(); - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - e.preventDefault(); - const prev = tabs[(idx - 1 + tabs.length) % tabs.length]; - prev.focus(); - prev.click(); - } - }); - } - listenersBound.scanTabs = true; - } - - function setScanMode(mode) { - scanMode = mode; - - // Update tab UI and ARIA states - if (elements.scanModeQuick) { - elements.scanModeQuick.classList.toggle('active', mode === 'quick'); - elements.scanModeQuick.setAttribute('aria-selected', mode === 'quick' ? 'true' : 'false'); - } - if (elements.scanModeDeep) { - elements.scanModeDeep.classList.toggle('active', mode === 'deep'); - elements.scanModeDeep.setAttribute('aria-selected', mode === 'deep' ? 'true' : 'false'); - } - - console.log('[WiFiMode] Scan mode set to:', mode); - } - - // ========================================================================== - // Scanning - // ========================================================================== - - async function startQuickScan() { - if (isScanning) return; - - // Check for agent mode conflicts - if (!await checkAgentConflicts()) { - return; - } - - console.log('[WiFiMode] Starting quick scan...'); - if (elements.quickScanBtn) elements.quickScanBtn.classList.add('btn-loading'); - setScanning(true, 'quick'); - - try { - const iface = elements.interfaceSelect?.value || null; - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const agentName = getCurrentAgentName(); - - let response; - if (isAgentMode) { - // Route through agent proxy - response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface, scan_type: 'quick' }), - }); - } else { - response = await fetch(`${CONFIG.apiBase}/scan/quick`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface }), - }); - } - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Quick scan failed'); - } - - const result = await response.json(); - console.log('[WiFiMode] Quick scan complete:', result); - - // Handle controller proxy response format (agent response is nested in 'result') - const scanResult = isAgentMode && result.result ? result.result : result; - - // Check for error first - if (scanResult.error || scanResult.status === 'error') { - console.error('[WiFiMode] Quick scan error from server:', scanResult.error || scanResult.message); - showError(scanResult.error || scanResult.message || 'Quick scan failed'); - setScanning(false); - return; - } - - // Handle agent response format - let accessPoints = scanResult.access_points || scanResult.networks || []; - - // Check if we got results - if (accessPoints.length === 0) { - // No error but no results - let msg = 'Quick scan found no networks in range.'; - if (scanResult.warnings && scanResult.warnings.length > 0) { - msg += ' Warnings: ' + scanResult.warnings.join('; '); - } - console.warn('[WiFiMode] ' + msg); - showError(msg + ' Try Deep Scan with monitor mode.'); - setScanning(false); - return; - } - - // Tag results with agent source - accessPoints.forEach(ap => { - ap._agent = agentName; - }); - - // Show any warnings even on success - if (scanResult.warnings && scanResult.warnings.length > 0) { - console.warn('[WiFiMode] Quick scan warnings:', scanResult.warnings); - } - - // Process results - processQuickScanResult({ ...scanResult, access_points: accessPoints }); - - // For quick scan, we're done after one scan - // But keep polling if user wants continuous updates - if (scanMode === 'quick') { - startQuickScanPolling(); - } - } catch (error) { - console.error('[WiFiMode] Quick scan error:', error); - showError(error.message + '. Try using Deep Scan instead.'); - setScanning(false); - } finally { - if (elements.quickScanBtn) elements.quickScanBtn.classList.remove('btn-loading'); - } - } - - async function startDeepScan() { - if (isScanning) return; - - // Check for agent mode conflicts - if (!await checkAgentConflicts()) { - return; - } - - console.log('[WiFiMode] Starting deep scan...'); - if (elements.deepScanBtn) elements.deepScanBtn.classList.add('btn-loading'); - setScanning(true, 'deep'); - - try { - const iface = elements.interfaceSelect?.value || null; - const band = document.getElementById('wifiBand')?.value || 'all'; - const channelConfig = buildChannelConfig(); - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - - let response; - if (isAgentMode) { - // Route through agent proxy - response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - interface: iface, - scan_type: 'deep', - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } else { - response = await fetch(`${CONFIG.apiBase}/scan/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - interface: iface, - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to start deep scan'); - } - - // Check for agent error in response - if (isAgentMode) { - const result = await response.json(); - const scanResult = result.result || result; - if (scanResult.status === 'error') { - throw new Error(scanResult.message || 'Agent failed to start deep scan'); - } - console.log('[WiFiMode] Agent deep scan started:', scanResult); - } - - // Start SSE stream for real-time updates (works with push-enabled agents) - startEventStream(); - - // Also start polling for agent data (works without push enabled) - if (isAgentMode) { - startAgentDeepScanPolling(); - } - } catch (error) { - console.error('[WiFiMode] Deep scan error:', error); - showError(error.message); - setScanning(false); - } finally { - if (elements.deepScanBtn) elements.deepScanBtn.classList.remove('btn-loading'); - } - } - - async function stopScan() { - console.log('[WiFiMode] Stopping scan...'); - - // Stop polling - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - - // Stop agent polling - stopAgentDeepScanPolling(); - - // Close event stream - if (eventSource) { - eventSource.close(); - eventSource = null; - } - - // Update UI immediately so mode transitions are responsive even if the - // backend needs extra time to terminate subprocesses. - setScanning(false); - - // Stop scan on server (local or agent) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const timeoutMs = isAgentMode ? 8000 : 2200; - const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; - const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } else if (scanMode === 'deep') { - await fetch(`${CONFIG.apiBase}/scan/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } - } catch (error) { - console.warn('[WiFiMode] Error stopping scan:', error); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } - - function setScanning(scanning, mode = null) { - isScanning = scanning; - if (mode) scanMode = mode; - - // Update buttons - if (elements.quickScanBtn) { - elements.quickScanBtn.style.display = scanning ? 'none' : 'inline-block'; - } - if (elements.deepScanBtn) { - elements.deepScanBtn.style.display = scanning ? 'none' : 'inline-block'; - } - if (elements.stopScanBtn) { - elements.stopScanBtn.style.display = scanning ? 'inline-block' : 'none'; - } - - // Update status - 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'; - } - - async function checkScanStatus() { - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const endpoint = isAgentMode - ? `/controller/agents/${currentAgent}/wifi/status` - : `${CONFIG.apiBase}/scan/status`; - - const response = await fetch(endpoint); - if (!response.ok) return; - - const data = await response.json(); - // Handle agent response format (may be nested in 'result') - const status = isAgentMode && data.result ? data.result : data; - - if (status.is_scanning || status.running) { - // Agent returns scan_type in params, local returns scan_mode - // Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick' - let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep'; - if (detectedMode === 'deepscan') detectedMode = 'deep'; - - setScanning(true, detectedMode); - if (detectedMode === 'deep') { - startEventStream(); - // Also start polling for agent mode (works without push enabled) - if (isAgentMode) { - startAgentDeepScanPolling(); - } - } else { - startQuickScanPolling(); - } - } - } catch (error) { - console.debug('[WiFiMode] Status check failed:', error); - } - } - - // ========================================================================== - // Quick Scan Polling - // ========================================================================== - - function startQuickScanPolling() { - if (pollTimer) return; - - pollTimer = setInterval(async () => { - if (!isScanning || scanMode !== 'quick') { - clearInterval(pollTimer); - pollTimer = null; - return; - } - - try { - const iface = elements.interfaceSelect?.value || null; - const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface }), - }); - - if (response.ok) { - const result = await response.json(); - processQuickScanResult(result); - } - } catch (error) { - console.debug('[WiFiMode] Poll error:', error); - } - }, CONFIG.pollInterval); - } - - function processQuickScanResult(result) { - // Update networks - result.access_points.forEach(ap => { - networks.set(ap.bssid, ap); - }); - - // Update channel stats (calculate from networks if not provided by API) - channelStats = result.channel_stats || []; - recommendations = result.recommendations || []; - - // If no channel stats from API, calculate from networks - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } - - // Update UI - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - - // Callbacks - result.access_points.forEach(ap => { - if (onNetworkUpdate) onNetworkUpdate(ap); - }); - } - - // ========================================================================== - // Agent Deep Scan Polling (fallback when push is not enabled) - // ========================================================================== - - function startAgentDeepScanPolling() { - if (agentPollTimer) return; - - console.log('[WiFiMode] Starting agent deep scan polling...'); - - agentPollTimer = setInterval(async () => { - if (!isScanning || scanMode !== 'deep') { - clearInterval(agentPollTimer); - agentPollTimer = null; - return; - } - - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - if (!isAgentMode) { - clearInterval(agentPollTimer); - agentPollTimer = null; - return; - } - - try { - const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`); - if (!response.ok) return; - - const result = await response.json(); - if (result.status !== 'success' || !result.data) return; - - const data = result.data.data || result.data; - const agentName = result.agent_name || 'Remote'; - - // Process networks - if (data.networks && Array.isArray(data.networks)) { - data.networks.forEach(net => { - net._agent = agentName; - handleStreamEvent({ - type: 'network_update', - network: net - }); - }); - } - - // Process clients - if (data.clients && Array.isArray(data.clients)) { - data.clients.forEach(client => { - client._agent = agentName; - handleStreamEvent({ - type: 'client_update', - client: client - }); - }); - } - - console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`); - - } catch (error) { - console.debug('[WiFiMode] Agent poll error:', error); - } - }, 2000); // Poll every 2 seconds - } - - function stopAgentDeepScanPolling() { - if (agentPollTimer) { - clearInterval(agentPollTimer); - agentPollTimer = null; - } - } - - // ========================================================================== - // SSE Event Stream - // ========================================================================== - - function startEventStream() { - if (eventSource) { - eventSource.close(); - } - - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const agentName = getCurrentAgentName(); - let streamUrl; - - if (isAgentMode) { - // Use multi-agent stream for remote agents - streamUrl = '/controller/stream/all'; - console.log('[WiFiMode] Starting multi-agent event stream...'); - } else { - streamUrl = `${CONFIG.apiBase}/stream`; - console.log('[WiFiMode] Starting local event stream...'); - } - - eventSource = new EventSource(streamUrl); - - eventSource.onopen = () => { - console.log('[WiFiMode] Event stream connected'); - }; - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - // For multi-agent stream, filter and transform data - if (isAgentMode) { - // Skip keepalive and non-wifi data - if (data.type === 'keepalive') return; - if (data.scan_type !== 'wifi') return; - - // Filter by current agent if not in "show all" mode - if (!showAllAgentsMode && typeof agents !== 'undefined') { - const currentAgentObj = agents.find(a => a.id == currentAgent); - if (currentAgentObj && data.agent_name && data.agent_name !== currentAgentObj.name) { - return; - } - } - - // Transform multi-agent payload to stream event format - if (data.payload && data.payload.networks) { - data.payload.networks.forEach(net => { - net._agent = data.agent_name || 'Unknown'; - handleStreamEvent({ - type: 'network_update', - network: net - }); - }); - } - if (data.payload && data.payload.clients) { - data.payload.clients.forEach(client => { - client._agent = data.agent_name || 'Unknown'; - handleStreamEvent({ - type: 'client_update', - client: client - }); - }); - } - } else { - // Local stream - tag with local - if (data.network) data.network._agent = 'Local'; - if (data.client) data.client._agent = 'Local'; - handleStreamEvent(data); - } - } catch (error) { - console.debug('[WiFiMode] Event parse error:', error); - } - }; - - eventSource.onerror = (error) => { - console.warn('[WiFiMode] Event stream error:', error); - if (isScanning) { - // Attempt to reconnect - setTimeout(() => { - if (isScanning && scanMode === 'deep') { - startEventStream(); - } - }, 3000); - } - }; - } - - function handleStreamEvent(event) { - switch (event.type) { - case 'network_update': - handleNetworkUpdate(event.network); - break; - - case 'client_update': - handleClientUpdate(event.client); - break; - - case 'probe_request': - handleProbeRequest(event.probe); - break; - - case 'hidden_revealed': - handleHiddenRevealed(event.bssid, event.revealed_essid); - break; - - case 'scan_started': - console.log('[WiFiMode] Scan started:', event); - break; - - case 'scan_stopped': - console.log('[WiFiMode] Scan stopped'); - setScanning(false); - break; - - case 'scan_error': - console.error('[WiFiMode] Scan error:', event.error); - showError(event.error); - setScanning(false); - break; - - case 'keepalive': - // Ignore keepalives - break; - - default: - console.debug('[WiFiMode] Unknown event type:', event.type); - } - } - - function handleNetworkUpdate(network) { - networks.set(network.bssid, network); - scheduleRender({ - table: true, - stats: true, - radar: true, - chart: true, - detail: selectedBssid === network.bssid, - }); - - if (onNetworkUpdate) onNetworkUpdate(network); - } - - function handleClientUpdate(client) { - clients.set(client.mac, client); - scheduleRender({ stats: true }); - - // Update client display if this client belongs to the selected network - updateClientInList(client); - - if (onClientUpdate) onClientUpdate(client); - } - - function handleProbeRequest(probe) { - probeRequests.push(probe); - if (probeRequests.length > CONFIG.maxProbes) { - probeRequests.shift(); - } - - if (onProbeRequest) onProbeRequest(probe); - } - - function handleHiddenRevealed(bssid, revealedSsid) { - const network = networks.get(bssid); - if (network) { - network.revealed_essid = revealedSsid; - network.display_name = `${revealedSsid} (revealed)`; - scheduleRender({ - table: true, - detail: selectedBssid === bssid, - }); - - // Show notification - showInfo(`Hidden SSID revealed: ${revealedSsid}`); - } - } - - // ========================================================================== - // Network Table - // ========================================================================== - - function initNetworkFilters() { - if (listenersBound.filters) return; - if (!elements.networkFilters) return; - - elements.networkFilters.addEventListener('click', (e) => { - if (e.target.matches('.wifi-filter-btn')) { - const filter = e.target.dataset.filter; - setNetworkFilter(filter); - } - }); - listenersBound.filters = true; - } - - function setNetworkFilter(filter) { - currentFilter = filter; - - // Update button states - if (elements.networkFilters) { - elements.networkFilters.querySelectorAll('.wifi-filter-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.filter === filter); - }); - } - - renderNetworks(); - } - - function initSortControls() { - if (listenersBound.sort) return; - - 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 }); - }); - }); - - listenersBound.sort = true; - } - - function scheduleRender(flags = {}) { - pendingRender.table = pendingRender.table || Boolean(flags.table); - pendingRender.stats = pendingRender.stats || Boolean(flags.stats); - pendingRender.radar = pendingRender.radar || Boolean(flags.radar); - pendingRender.chart = pendingRender.chart || Boolean(flags.chart); - pendingRender.detail = pendingRender.detail || Boolean(flags.detail); - - if (renderFramePending) return; - renderFramePending = true; - - requestAnimationFrame(() => { - renderFramePending = false; - - if (pendingRender.table) renderNetworks(); - if (pendingRender.stats) updateStats(); - if (pendingRender.radar) updateProximityRadar(); - if (pendingRender.chart) updateChannelChart(); - if (pendingRender.detail && selectedBssid) { - updateDetailPanel(selectedBssid, { refreshClients: false }); - } - - pendingRender.table = false; - pendingRender.stats = false; - pendingRender.radar = false; - pendingRender.chart = false; - pendingRender.detail = false; - }); - } - - function renderNetworks() { - if (!elements.networkList) return; - - // Filter networks - let filtered = Array.from(networks.values()); - - switch (currentFilter) { - case 'hidden': - filtered = filtered.filter(n => n.is_hidden); - break; - case 'open': - filtered = filtered.filter(n => n.security === 'Open'); - break; - case 'strong': - filtered = filtered.filter(n => n.rssi_current && n.rssi_current >= -60); - break; - case '2.4': - filtered = filtered.filter(n => n.band === '2.4GHz'); - break; - case '5': - filtered = filtered.filter(n => n.band === '5GHz'); - break; - } - - // Sort networks - filtered.sort((a, b) => { - let aVal, bVal; - - switch (currentSort.field) { - case 'rssi': - aVal = a.rssi_current || -100; - bVal = b.rssi_current || -100; - break; - case 'channel': - aVal = a.channel || 0; - bVal = b.channel || 0; - break; - case 'essid': - aVal = (a.essid || '').toLowerCase(); - bVal = (b.essid || '').toLowerCase(); - break; - case 'clients': - aVal = a.client_count || 0; - bVal = b.client_count || 0; - break; - default: - aVal = a.rssi_current || -100; - bVal = b.rssi_current || -100; - } - - if (currentSort.order === 'desc') { - return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; - } else { - return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } - }); - - 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; - } - - // Render list - elements.networkList.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); - - // 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'); - } - } - - 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 : '?'} -
-
-
- `; - } - - function updateNetworkRow(network) { - scheduleRender({ - table: true, - detail: selectedBssid === network.bssid, - }); - } - - function selectNetwork(bssid) { - selectedBssid = bssid; - - // Update row selection - elements.networkList?.querySelectorAll('.network-row').forEach(row => { - row.classList.toggle('selected', row.dataset.bssid === bssid); - }); - - // Update detail panel - updateDetailPanel(bssid); - - // Highlight on radar - if (typeof WiFiProximityRadar !== 'undefined') { - WiFiProximityRadar.highlightNetwork(bssid); - } - } - - // ========================================================================== - // Detail Panel - // ========================================================================== - - function updateDetailPanel(bssid, options = {}) { - const { refreshClients = true } = options; - if (!elements.detailDrawer) return; - - const network = networks.get(bssid); - if (!network) { - closeDetail(); - return; - } - - // Update drawer header - if (elements.detailEssid) { - elements.detailEssid.textContent = network.display_name || network.essid || '[Hidden SSID]'; - } - if (elements.detailBssid) { - elements.detailBssid.textContent = network.bssid; - } - - // Update detail stats - if (elements.detailRssi) { - elements.detailRssi.textContent = network.rssi_current ? `${network.rssi_current} dBm` : '--'; - } - if (elements.detailChannel) { - elements.detailChannel.textContent = network.channel || '--'; - } - if (elements.detailBand) { - elements.detailBand.textContent = network.band || '--'; - } - if (elements.detailSecurity) { - elements.detailSecurity.textContent = network.security || '--'; - } - if (elements.detailCipher) { - elements.detailCipher.textContent = network.cipher || '--'; - } - if (elements.detailVendor) { - elements.detailVendor.textContent = network.vendor || 'Unknown'; - } - if (elements.detailClients) { - elements.detailClients.textContent = network.client_count || '0'; - } - if (elements.detailFirstSeen) { - elements.detailFirstSeen.textContent = formatTime(network.first_seen); - } - - // Show the drawer - elements.detailDrawer.classList.add('open'); - - // Fetch and display clients for this network - if (refreshClients) { - fetchClientsForNetwork(network.bssid); - } - } - - function closeDetail() { - selectedBssid = null; - if (elements.detailDrawer) { - elements.detailDrawer.classList.remove('open'); - } - elements.networkList?.querySelectorAll('.network-row').forEach(row => { - row.classList.remove('selected'); - }); - } - - // ========================================================================== - // Client Display - // ========================================================================== - - async function fetchClientsForNetwork(bssid) { - if (!elements.detailClientList) return; - const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); - - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); - elements.detailClientList.style.display = 'block'; - } - - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - let response; - - if (isAgentMode) { - // Route through agent proxy - response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); - } else { - response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); - } - - if (!response.ok) { - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - return; - } - - const data = await response.json(); - // Handle agent response format (may be nested in 'result') - const result = isAgentMode && data.result ? data.result : data; - const clientList = result.clients || []; - - if (clientList.length > 0) { - renderClientList(clientList, bssid); - elements.detailClientList.style.display = 'block'; - } else { - const countBadge = document.getElementById('wifiClientCountBadge'); - if (countBadge) countBadge.textContent = '0'; - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } catch (error) { - console.debug('[WiFiMode] Error fetching clients:', error); - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } - - function renderClientList(clientList, bssid) { - const container = elements.detailClientList?.querySelector('.wifi-client-list'); - const countBadge = document.getElementById('wifiClientCountBadge'); - - if (!container) return; - - // Update count badge - if (countBadge) { - countBadge.textContent = clientList.length; - } - - // Render client cards - container.innerHTML = clientList.map(client => { - const rssi = client.rssi_current; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - - // Format last seen time - const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--'; - - // Build probed SSIDs badges - let probesHtml = ''; - if (client.probed_ssids && client.probed_ssids.length > 0) { - const probes = client.probed_ssids.slice(0, 5); // Show max 5 - probesHtml = ` -
- ${probes.map(ssid => `${escapeHtml(ssid)}`).join('')} - ${client.probed_ssids.length > 5 ? `+${client.probed_ssids.length - 5}` : ''} -
- `; - } - - return ` -
-
- ${escapeHtml(client.mac)} - ${escapeHtml(client.vendor || 'Unknown vendor')} - ${probesHtml} -
-
- ${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'} - ${lastSeen} -
-
- `; - }).join(''); - } - - function updateClientInList(client) { - // Check if this client belongs to the currently selected network - if (!selectedBssid || client.associated_bssid !== selectedBssid) { - return; - } - - const container = elements.detailClientList?.querySelector('.wifi-client-list'); - if (!container) return; - - const existingCard = container.querySelector(`[data-mac="${client.mac}"]`); - - if (existingCard) { - // Update existing card's RSSI and last seen - const rssiEl = existingCard.querySelector('.wifi-client-rssi'); - const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen'); - - if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) { - const rssi = client.rssi_current; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - rssiEl.textContent = rssi + ' dBm'; - rssiEl.className = 'wifi-client-rssi ' + signalClass; - } - - if (lastSeenEl && client.last_seen) { - lastSeenEl.textContent = formatTime(client.last_seen); - } - } else { - // New client for this network - re-fetch the full list - fetchClientsForNetwork(selectedBssid); - } - } - - // ========================================================================== - // Statistics - // ========================================================================== - - function updateStats() { - const networksList = Array.from(networks.values()); - - // Update counts in status bar - if (elements.networkCount) { - elements.networkCount.textContent = networks.size; - } - if (elements.clientCount) { - elements.clientCount.textContent = clients.size; - } - if (elements.hiddenCount) { - const hidden = networksList.filter(n => n.is_hidden).length; - elements.hiddenCount.textContent = hidden; - } - - // Update security counts - const securityCounts = { wpa3: 0, wpa2: 0, wep: 0, open: 0 }; - networksList.forEach(n => { - const sec = (n.security || '').toLowerCase(); - if (sec.includes('wpa3')) securityCounts.wpa3++; - else if (sec.includes('wpa2') || sec.includes('wpa')) securityCounts.wpa2++; - else if (sec.includes('wep')) securityCounts.wep++; - else if (sec === 'open' || sec === '') securityCounts.open++; - }); - - if (elements.openCount) elements.openCount.textContent = securityCounts.open; - - // Update zone summary - const zoneCounts = { immediate: 0, near: 0, far: 0 }; - networksList.forEach(n => { - const rssi = n.rssi_current; - if (rssi >= -50) zoneCounts.immediate++; - else if (rssi >= -70) zoneCounts.near++; - else zoneCounts.far++; - }); - - if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate; - if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near; - if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far; - } - - // ========================================================================== - // Proximity Radar - // ========================================================================== - - function initProximityRadar() { - if (!elements.proximityRadar) return; - - // Initialize radar component - if (typeof ProximityRadar !== 'undefined') { - ProximityRadar.init('wifiProximityRadar', { - mode: 'wifi', - size: 280, - onDeviceClick: (bssid) => selectNetwork(bssid), - }); - } - } - - function updateProximityRadar() { - if (typeof ProximityRadar === 'undefined') return; - - // Convert networks to radar-compatible format - const devices = Array.from(networks.values()).map(n => ({ - device_key: n.bssid, - device_id: n.bssid, - name: n.essid || '[Hidden]', - rssi_current: n.rssi_current, - rssi_ema: n.rssi_ema, - proximity_band: n.proximity_band, - estimated_distance_m: n.estimated_distance_m, - is_new: n.is_new, - heuristic_flags: n.heuristic_flags || [], - })); - - ProximityRadar.updateDevices(devices); - } - - // ========================================================================== - // Channel Chart - // ========================================================================== - - function initChannelChart() { - if (!elements.channelChart) return; - - // Initialize channel chart component - if (typeof ChannelChart !== 'undefined') { - ChannelChart.init('wifiChannelChart'); - } - - // Band tabs - if (elements.channelBandTabs) { - elements.channelBandTabs.addEventListener('click', (e) => { - if (e.target.matches('.channel-band-tab')) { - const band = e.target.dataset.band; - elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => { - t.classList.toggle('active', t.dataset.band === band); - }); - updateChannelChart(band); - } - }); - } - } - - function calculateChannelStats() { - // Calculate channel stats from current networks - const stats = {}; - const networksList = Array.from(networks.values()); - - // Initialize all channels - // 2.4 GHz: channels 1-13 - for (let ch = 1; ch <= 13; ch++) { - stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; - } - // 5 GHz: common channels - [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => { - stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; - }); - - // Count APs per channel - networksList.forEach(net => { - const ch = parseInt(net.channel); - if (stats[ch]) { - stats[ch].ap_count++; - stats[ch].client_count += (net.client_count || 0); - } - }); - - // Calculate utilization score (0-1) - const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count)); - Object.values(stats).forEach(s => { - s.utilization_score = s.ap_count / maxAPs; - }); - - return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); - } - - function updateChannelChart(band) { - if (typeof ChannelChart === 'undefined') return; - - // Use the currently active band tab if no band specified - if (!band) { - const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active'); - band = activeTab ? activeTab.dataset.band : '2.4'; - } - - // Recalculate channel stats from networks if needed - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } - - // Filter stats by band - const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz'; - const filteredStats = channelStats.filter(s => s.band === bandFilter); - const filteredRecs = recommendations.filter(r => r.band === bandFilter); - - ChannelChart.update(filteredStats, filteredRecs); - } - - // ========================================================================== - // Export - // ========================================================================== - - async function exportData(format) { - try { - const response = await fetch(`${CONFIG.apiBase}/export?format=${format}&type=all`); - if (!response.ok) throw new Error('Export failed'); - - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `wifi_scan_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.${format}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } catch (error) { - console.error('[WiFiMode] Export error:', error); - showError('Export failed: ' + error.message); - } - } - - // ========================================================================== - // Utilities - // ========================================================================== - - function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - function formatTime(isoString) { - if (!isoString) return '-'; - const date = new Date(isoString); - return date.toLocaleTimeString(); - } - - function showError(message) { - // Use global notification if available - if (typeof showNotification === 'function') { - showNotification('WiFi Error', message, 'error'); - } else { - console.error('[WiFiMode]', message); - } - } - - function showInfo(message) { - if (typeof showNotification === 'function') { - showNotification('WiFi', message, 'info'); - } else { - console.log('[WiFiMode]', message); - } - } - - // ========================================================================== - // Agent Handling - // ========================================================================== - - /** - * Handle agent change - refresh interfaces and optionally clear data. - * Called when user selects a different agent. - */ - function handleAgentChange() { - const currentAgentId = typeof currentAgent !== 'undefined' ? currentAgent : 'local'; - - // Check if agent actually changed - if (lastAgentId === currentAgentId) return; - - console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId); - - // Stop UI polling only - don't stop the actual scan on the agent - // The agent should continue running independently - if (isScanning) { - stopAgentDeepScanPolling(); - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - if (eventSource) { - eventSource.close(); - eventSource = null; - } - setScanning(false); - } - - // Clear existing data when switching agents (unless "Show All" is enabled) - if (!showAllAgentsMode) { - clearData(); - showInfo(`Switched to ${getCurrentAgentName()} - previous data cleared`); - } - - // Refresh capabilities for new agent - checkCapabilities(); - - // Check if new agent already has a scan running - checkScanStatus(); - - lastAgentId = currentAgentId; - } - - /** - * Clear all collected data. - */ - function clearData() { - networks.clear(); - clients.clear(); - probeRequests = []; - channelStats = []; - recommendations = []; - if (selectedBssid) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - } - - /** - * Toggle "Show All Agents" mode. - * When enabled, displays combined WiFi results from all agents. - */ - function toggleShowAllAgents(enabled) { - showAllAgentsMode = enabled; - console.log('[WiFiMode] Show all agents mode:', enabled); - - if (enabled) { - // If currently scanning, switch to multi-agent stream - if (isScanning && eventSource) { - eventSource.close(); - startEventStream(); - } - showInfo('Showing WiFi networks from all agents'); - } else { - // Filter to current agent only - filterToCurrentAgent(); - } - } - - /** - * Filter networks to only show those from current agent. - */ - function filterToCurrentAgent() { - const agentName = getCurrentAgentName(); - const toRemove = []; - - networks.forEach((network, bssid) => { - if (network._agent && network._agent !== agentName) { - toRemove.push(bssid); - } - }); - - toRemove.forEach(bssid => networks.delete(bssid)); - - // Also filter clients - const clientsToRemove = []; - clients.forEach((client, mac) => { - if (client._agent && client._agent !== agentName) { - clientsToRemove.push(mac); - } - }); - clientsToRemove.forEach(mac => clients.delete(mac)); - if (selectedBssid && !networks.has(selectedBssid)) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - } - - /** - * Refresh WiFi interfaces from current agent. - * Called when agent changes. - */ - async function refreshInterfaces() { - await checkCapabilities(); - } - - // ========================================================================== - // Public API - // ========================================================================== - - return { - init, - startQuickScan, - startDeepScan, - stopScan, - selectNetwork, - closeDetail, - setFilter: setNetworkFilter, - exportData, - checkCapabilities, - - // Agent handling - handleAgentChange, - clearData, - toggleShowAllAgents, - refreshInterfaces, - - // Getters - getNetworks: () => Array.from(networks.values()), - getClients: () => Array.from(clients.values()), - getProbes: () => [...probeRequests], - isScanning: () => isScanning, - getScanMode: () => scanMode, - isShowAllAgents: () => showAllAgentsMode, - - // Callbacks - onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, - onClientUpdate: (cb) => { onClientUpdate = cb; }, - onProbeRequest: (cb) => { onProbeRequest = cb; }, - - // Lifecycle - destroy, - }; - - /** - * Destroy — close SSE stream and clear polling timers for clean mode switching. - */ - function destroy() { - if (eventSource) { - eventSource.close(); - eventSource = null; - } - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - if (agentPollTimer) { - clearInterval(agentPollTimer); - agentPollTimer = null; - } - } -})(); - -// Auto-initialize when DOM is ready -document.addEventListener('DOMContentLoaded', () => { - // Only init if we're in WiFi mode - if (typeof currentMode !== 'undefined' && currentMode === 'wifi') { - WiFiMode.init(); - } -}); +/** + * WiFi Mode Controller (v2) + * + * Unified WiFi scanning with dual-mode architecture: + * - Quick Scan: System tools without monitor mode + * - Deep Scan: airodump-ng with monitor mode + * + * Features: + * - Proximity radar visualization + * - Channel utilization analysis + * - Hidden SSID correlation + * - Real-time SSE streaming + */ + +const WiFiMode = (function() { + 'use strict'; + + // ========================================================================== + // Configuration + // ========================================================================== + + const CONFIG = { + apiBase: '/wifi/v2', + pollInterval: 5000, + keepaliveTimeout: 30000, + maxNetworks: 500, + maxClients: 500, + maxProbes: 1000, + }; + + // ========================================================================== + // Agent Support + // ========================================================================== + + /** + * Get the API base URL, routing through agent proxy if agent is selected. + */ + function getApiBase() { + if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') { + return `/controller/agents/${currentAgent}/wifi/v2`; + } + return CONFIG.apiBase; + } + + /** + * Get the current agent name for tagging data. + */ + function getCurrentAgentName() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return 'Local'; + } + if (typeof agents !== 'undefined') { + const agent = agents.find(a => a.id == currentAgent); + return agent ? agent.name : `Agent ${currentAgent}`; + } + return `Agent ${currentAgent}`; + } + + /** + * Check for agent mode conflicts before starting WiFi scan. + */ + async function checkAgentConflicts() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return true; + } + if (typeof checkAgentModeConflict === 'function') { + return await checkAgentModeConflict('wifi'); + } + return true; + } + + function getChannelPresetList(preset) { + switch (preset) { + case '2.4-common': + return '1,6,11'; + case '2.4-all': + return '1,2,3,4,5,6,7,8,9,10,11,12,13'; + case '5-low': + return '36,40,44,48'; + case '5-mid': + return '52,56,60,64'; + case '5-high': + return '149,153,157,161,165'; + default: + return ''; + } + } + + function buildChannelConfig() { + const preset = document.getElementById('wifiChannelPreset')?.value || ''; + const listInput = document.getElementById('wifiChannelList')?.value || ''; + const singleInput = document.getElementById('wifiChannel')?.value || ''; + + const listValue = listInput.trim(); + const presetValue = getChannelPresetList(preset); + + const channels = listValue || presetValue || ''; + const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); + + return { + channels: channels || null, + channel: Number.isFinite(channel) ? channel : null, + }; + } + + // ========================================================================== + // State + // ========================================================================== + + let isScanning = false; + let scanMode = 'quick'; // 'quick' or 'deep' + let eventSource = null; + let pollTimer = null; + let agentPollTimer = null; + + // Data stores + let networks = new Map(); // bssid -> network + let clients = new Map(); // mac -> client + let probeRequests = []; + let channelStats = []; + let recommendations = []; + + // UI state + let selectedBssid = null; + let currentFilter = 'all'; + let currentSort = { field: 'rssi', order: 'desc' }; + let renderFramePending = false; + const pendingRender = { + table: false, + stats: false, + radar: false, + chart: false, + detail: false, + }; + const listenersBound = { + scanTabs: false, + filters: false, + sort: false, + }; + + // Agent state + let showAllAgentsMode = false; // Show combined results from all agents + let lastAgentId = null; // Track agent switches + + // Capabilities + let capabilities = null; + + // Callbacks for external integration + let onNetworkUpdate = null; + let onClientUpdate = null; + let onProbeRequest = null; + + // ========================================================================== + // Initialization + // ========================================================================== + + function init() { + console.log('[WiFiMode] Initializing...'); + + // Cache DOM elements + cacheDOM(); + + // Check capabilities + checkCapabilities(); + + // Initialize components + initScanModeTabs(); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + + // Check if already scanning + checkScanStatus(); + + console.log('[WiFiMode] Initialized'); + } + + // DOM element cache + let elements = {}; + + function cacheDOM() { + elements = { + // Scan controls + quickScanBtn: document.getElementById('wifiQuickScanBtn'), + deepScanBtn: document.getElementById('wifiDeepScanBtn'), + stopScanBtn: document.getElementById('wifiStopScanBtn'), + scanModeQuick: document.getElementById('wifiScanModeQuick'), + scanModeDeep: document.getElementById('wifiScanModeDeep'), + + // Status bar + scanIndicator: document.getElementById('wifiScanIndicator'), + openCount: document.getElementById('wifiOpenCount'), + networkCount: document.getElementById('wifiNetworkCount'), + clientCount: document.getElementById('wifiClientCount'), + hiddenCount: document.getElementById('wifiHiddenCount'), + + // Network list + networkList: document.getElementById('wifiNetworkList'), + networkFilters: document.getElementById('wifiNetworkFilters'), + + // Visualizations + proximityRadar: document.getElementById('wifiProximityRadar'), + channelChart: document.getElementById('wifiChannelChart'), + channelBandTabs: document.getElementById('wifiChannelBandTabs'), + + // Zone summary + zoneImmediate: document.getElementById('wifiZoneImmediate'), + zoneNear: document.getElementById('wifiZoneNear'), + zoneFar: document.getElementById('wifiZoneFar'), + + // Detail drawer + detailDrawer: document.getElementById('wifiDetailDrawer'), + detailEssid: document.getElementById('wifiDetailEssid'), + detailBssid: document.getElementById('wifiDetailBssid'), + detailRssi: document.getElementById('wifiDetailRssi'), + detailChannel: document.getElementById('wifiDetailChannel'), + detailBand: document.getElementById('wifiDetailBand'), + detailSecurity: document.getElementById('wifiDetailSecurity'), + detailCipher: document.getElementById('wifiDetailCipher'), + detailVendor: document.getElementById('wifiDetailVendor'), + detailClients: document.getElementById('wifiDetailClients'), + detailFirstSeen: document.getElementById('wifiDetailFirstSeen'), + detailClientList: document.getElementById('wifiDetailClientList'), + + // Interface select + interfaceSelect: document.getElementById('wifiInterfaceSelect'), + + // Capability status + capabilityStatus: document.getElementById('wifiCapabilityStatus'), + + // Export buttons + exportCsvBtn: document.getElementById('wifiExportCsv'), + exportJsonBtn: document.getElementById('wifiExportJson'), + }; + } + + // ========================================================================== + // Capabilities + // ========================================================================== + + async function checkCapabilities() { + const capBtn = document.getElementById('wifiQuickScanBtn'); + if (capBtn) capBtn.classList.add('btn-loading'); + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; + + if (isAgentMode) { + // Fetch capabilities from agent via controller proxy + response = await fetch(`/controller/agents/${currentAgent}?refresh=true`); + if (!response.ok) throw new Error('Failed to fetch agent capabilities'); + + const data = await response.json(); + // Extract WiFi capabilities from agent data + if (data.agent && data.agent.capabilities) { + const agentCaps = data.agent.capabilities; + const agentInterfaces = data.agent.interfaces || {}; + + // Build WiFi-compatible capabilities object + capabilities = { + can_quick_scan: agentCaps.wifi || false, + can_deep_scan: agentCaps.wifi || false, + interfaces: (agentInterfaces.wifi_interfaces || []).map(iface => ({ + name: iface.name || iface, + supports_monitor: iface.supports_monitor !== false + })), + default_interface: agentInterfaces.default_wifi || null, + preferred_quick_tool: 'agent', + issues: [] + }; + console.log('[WiFiMode] Agent capabilities:', capabilities); + } else { + throw new Error('Agent does not support WiFi mode'); + } + } else { + // Local capabilities + response = await fetch(`${CONFIG.apiBase}/capabilities`); + if (!response.ok) throw new Error('Failed to fetch capabilities'); + capabilities = await response.json(); + console.log('[WiFiMode] Local capabilities:', capabilities); + } + + updateCapabilityUI(); + populateInterfaceSelect(); + } catch (error) { + console.error('[WiFiMode] Capability check failed:', error); + showCapabilityError('Failed to check WiFi capabilities'); + } finally { + if (capBtn) capBtn.classList.remove('btn-loading'); + } + } + + function updateCapabilityUI() { + if (!capabilities || !elements.capabilityStatus) return; + + let html = ''; + + if (!capabilities.can_quick_scan && !capabilities.can_deep_scan) { + html = ` +
+ WiFi scanning not available +
    + ${capabilities.issues.map(i => `
  • ${escapeHtml(i)}
  • `).join('')} +
+
+ `; + } else { + // Show available modes + const modes = []; + if (capabilities.can_quick_scan) modes.push('Quick Scan'); + if (capabilities.can_deep_scan) modes.push('Deep Scan'); + + html = ` +
+ Available modes: ${modes.join(', ')} + ${capabilities.preferred_quick_tool ? ` (using ${capabilities.preferred_quick_tool})` : ''} +
+ `; + + if (capabilities.issues.length > 0) { + html += ` +
+ ${capabilities.issues.join('. ')} +
+ `; + } + } + + elements.capabilityStatus.innerHTML = html; + elements.capabilityStatus.style.display = html ? 'block' : 'none'; + + // Enable/disable scan buttons based on capabilities + if (elements.quickScanBtn) { + elements.quickScanBtn.disabled = !capabilities.can_quick_scan; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.disabled = !capabilities.can_deep_scan; + } + } + + function showCapabilityError(message) { + if (!elements.capabilityStatus) return; + + elements.capabilityStatus.innerHTML = ` +
${escapeHtml(message)}
+ `; + elements.capabilityStatus.style.display = 'block'; + } + + function populateInterfaceSelect() { + if (!elements.interfaceSelect || !capabilities) return; + + elements.interfaceSelect.innerHTML = ''; + + if (capabilities.interfaces.length === 0) { + elements.interfaceSelect.innerHTML = ''; + return; + } + + capabilities.interfaces.forEach(iface => { + const option = document.createElement('option'); + option.value = iface.name; + option.textContent = `${iface.name}${iface.supports_monitor ? ' (monitor capable)' : ''}`; + elements.interfaceSelect.appendChild(option); + }); + + // Select default + if (capabilities.default_interface) { + elements.interfaceSelect.value = capabilities.default_interface; + } + } + + // ========================================================================== + // Scan Mode Tabs + // ========================================================================== + + function initScanModeTabs() { + if (listenersBound.scanTabs) return; + if (elements.scanModeQuick) { + elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); + } + // Arrow key navigation between tabs + const tabContainer = document.querySelector('.wifi-scan-mode-tabs'); + if (tabContainer) { + tabContainer.addEventListener('keydown', (e) => { + const tabs = Array.from(tabContainer.querySelectorAll('[role="tab"]')); + const idx = tabs.indexOf(document.activeElement); + if (idx === -1) return; + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + const next = tabs[(idx + 1) % tabs.length]; + next.focus(); + next.click(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + const prev = tabs[(idx - 1 + tabs.length) % tabs.length]; + prev.focus(); + prev.click(); + } + }); + } + listenersBound.scanTabs = true; + } + + function setScanMode(mode) { + scanMode = mode; + + // Update tab UI and ARIA states + if (elements.scanModeQuick) { + elements.scanModeQuick.classList.toggle('active', mode === 'quick'); + elements.scanModeQuick.setAttribute('aria-selected', mode === 'quick' ? 'true' : 'false'); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.classList.toggle('active', mode === 'deep'); + elements.scanModeDeep.setAttribute('aria-selected', mode === 'deep' ? 'true' : 'false'); + } + + console.log('[WiFiMode] Scan mode set to:', mode); + } + + // ========================================================================== + // Scanning + // ========================================================================== + + async function startQuickScan() { + if (isScanning) return; + + // Check for agent mode conflicts + if (!await checkAgentConflicts()) { + return; + } + + console.log('[WiFiMode] Starting quick scan...'); + if (elements.quickScanBtn) elements.quickScanBtn.classList.add('btn-loading'); + setScanning(true, 'quick'); + + try { + const iface = elements.interfaceSelect?.value || null; + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const agentName = getCurrentAgentName(); + + let response; + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface, scan_type: 'quick' }), + }); + } else { + response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Quick scan failed'); + } + + const result = await response.json(); + console.log('[WiFiMode] Quick scan complete:', result); + + // Handle controller proxy response format (agent response is nested in 'result') + const scanResult = isAgentMode && result.result ? result.result : result; + + // Check for error first + if (scanResult.error || scanResult.status === 'error') { + console.error('[WiFiMode] Quick scan error from server:', scanResult.error || scanResult.message); + showError(scanResult.error || scanResult.message || 'Quick scan failed'); + setScanning(false); + return; + } + + // Handle agent response format + let accessPoints = scanResult.access_points || scanResult.networks || []; + + // Check if we got results + if (accessPoints.length === 0) { + // No error but no results + let msg = 'Quick scan found no networks in range.'; + if (scanResult.warnings && scanResult.warnings.length > 0) { + msg += ' Warnings: ' + scanResult.warnings.join('; '); + } + console.warn('[WiFiMode] ' + msg); + showError(msg + ' Try Deep Scan with monitor mode.'); + setScanning(false); + return; + } + + // Tag results with agent source + accessPoints.forEach(ap => { + ap._agent = agentName; + }); + + // Show any warnings even on success + if (scanResult.warnings && scanResult.warnings.length > 0) { + console.warn('[WiFiMode] Quick scan warnings:', scanResult.warnings); + } + + // Process results + processQuickScanResult({ ...scanResult, access_points: accessPoints }); + + // For quick scan, we're done after one scan + // But keep polling if user wants continuous updates + if (scanMode === 'quick') { + startQuickScanPolling(); + } + } catch (error) { + console.error('[WiFiMode] Quick scan error:', error); + showError(error.message + '. Try using Deep Scan instead.'); + setScanning(false); + } finally { + if (elements.quickScanBtn) elements.quickScanBtn.classList.remove('btn-loading'); + } + } + + async function startDeepScan() { + if (isScanning) return; + + // Check for agent mode conflicts + if (!await checkAgentConflicts()) { + return; + } + + console.log('[WiFiMode] Starting deep scan...'); + if (elements.deepScanBtn) elements.deepScanBtn.classList.add('btn-loading'); + setScanning(true, 'deep'); + + try { + const iface = elements.interfaceSelect?.value || null; + const band = document.getElementById('wifiBand')?.value || 'all'; + const channelConfig = buildChannelConfig(); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + + let response; + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + scan_type: 'deep', + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } else { + response = await fetch(`${CONFIG.apiBase}/scan/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start deep scan'); + } + + // Check for agent error in response + if (isAgentMode) { + const result = await response.json(); + const scanResult = result.result || result; + if (scanResult.status === 'error') { + throw new Error(scanResult.message || 'Agent failed to start deep scan'); + } + console.log('[WiFiMode] Agent deep scan started:', scanResult); + } + + // Start SSE stream for real-time updates (works with push-enabled agents) + startEventStream(); + + // Also start polling for agent data (works without push enabled) + if (isAgentMode) { + startAgentDeepScanPolling(); + } + } catch (error) { + console.error('[WiFiMode] Deep scan error:', error); + showError(error.message); + setScanning(false); + } finally { + if (elements.deepScanBtn) elements.deepScanBtn.classList.remove('btn-loading'); + } + } + + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); + + // Stop polling + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + + // Stop agent polling + stopAgentDeepScanPolling(); + + // Close event stream + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Update UI immediately so mode transitions are responsive even if the + // backend needs extra time to terminate subprocesses. + setScanning(false); + + // Stop scan on server (local or agent) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else if (scanMode === 'deep') { + await fetch(`${CONFIG.apiBase}/scan/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + function setScanning(scanning, mode = null) { + isScanning = scanning; + if (mode) scanMode = mode; + + // Update buttons + if (elements.quickScanBtn) { + elements.quickScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.stopScanBtn) { + elements.stopScanBtn.style.display = scanning ? 'inline-block' : 'none'; + } + + // Update status + 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'; + } + + async function checkScanStatus() { + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/wifi/status` + : `${CONFIG.apiBase}/scan/status`; + + const response = await fetch(endpoint); + if (!response.ok) return; + + const data = await response.json(); + // Handle agent response format (may be nested in 'result') + const status = isAgentMode && data.result ? data.result : data; + + if (status.is_scanning || status.running) { + // Agent returns scan_type in params, local returns scan_mode + // Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick' + let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep'; + if (detectedMode === 'deepscan') detectedMode = 'deep'; + + setScanning(true, detectedMode); + if (detectedMode === 'deep') { + startEventStream(); + // Also start polling for agent mode (works without push enabled) + if (isAgentMode) { + startAgentDeepScanPolling(); + } + } else { + startQuickScanPolling(); + } + } + } catch (error) { + console.debug('[WiFiMode] Status check failed:', error); + } + } + + // ========================================================================== + // Quick Scan Polling + // ========================================================================== + + function startQuickScanPolling() { + if (pollTimer) return; + + pollTimer = setInterval(async () => { + if (!isScanning || scanMode !== 'quick') { + clearInterval(pollTimer); + pollTimer = null; + return; + } + + try { + const iface = elements.interfaceSelect?.value || null; + const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + + if (response.ok) { + const result = await response.json(); + processQuickScanResult(result); + } + } catch (error) { + console.debug('[WiFiMode] Poll error:', error); + } + }, CONFIG.pollInterval); + } + + function processQuickScanResult(result) { + // Update networks + result.access_points.forEach(ap => { + networks.set(ap.bssid, ap); + }); + + // Update channel stats (calculate from networks if not provided by API) + channelStats = result.channel_stats || []; + recommendations = result.recommendations || []; + + // If no channel stats from API, calculate from networks + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Update UI + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + + // Callbacks + result.access_points.forEach(ap => { + if (onNetworkUpdate) onNetworkUpdate(ap); + }); + } + + // ========================================================================== + // Agent Deep Scan Polling (fallback when push is not enabled) + // ========================================================================== + + function startAgentDeepScanPolling() { + if (agentPollTimer) return; + + console.log('[WiFiMode] Starting agent deep scan polling...'); + + agentPollTimer = setInterval(async () => { + if (!isScanning || scanMode !== 'deep') { + clearInterval(agentPollTimer); + agentPollTimer = null; + return; + } + + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + if (!isAgentMode) { + clearInterval(agentPollTimer); + agentPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`); + if (!response.ok) return; + + const result = await response.json(); + if (result.status !== 'success' || !result.data) return; + + const data = result.data.data || result.data; + const agentName = result.agent_name || 'Remote'; + + // Process networks + if (data.networks && Array.isArray(data.networks)) { + data.networks.forEach(net => { + net._agent = agentName; + handleStreamEvent({ + type: 'network_update', + network: net + }); + }); + } + + // Process clients + if (data.clients && Array.isArray(data.clients)) { + data.clients.forEach(client => { + client._agent = agentName; + handleStreamEvent({ + type: 'client_update', + client: client + }); + }); + } + + console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`); + + } catch (error) { + console.debug('[WiFiMode] Agent poll error:', error); + } + }, 2000); // Poll every 2 seconds + } + + function stopAgentDeepScanPolling() { + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } + + // ========================================================================== + // SSE Event Stream + // ========================================================================== + + function startEventStream() { + if (eventSource) { + eventSource.close(); + } + + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const agentName = getCurrentAgentName(); + let streamUrl; + + if (isAgentMode) { + // Use multi-agent stream for remote agents + streamUrl = '/controller/stream/all'; + console.log('[WiFiMode] Starting multi-agent event stream...'); + } else { + streamUrl = `${CONFIG.apiBase}/stream`; + console.log('[WiFiMode] Starting local event stream...'); + } + + eventSource = new EventSource(streamUrl); + + eventSource.onopen = () => { + console.log('[WiFiMode] Event stream connected'); + }; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // For multi-agent stream, filter and transform data + if (isAgentMode) { + // Skip keepalive and non-wifi data + if (data.type === 'keepalive') return; + if (data.scan_type !== 'wifi') return; + + // Filter by current agent if not in "show all" mode + if (!showAllAgentsMode && typeof agents !== 'undefined') { + const currentAgentObj = agents.find(a => a.id == currentAgent); + if (currentAgentObj && data.agent_name && data.agent_name !== currentAgentObj.name) { + return; + } + } + + // Transform multi-agent payload to stream event format + if (data.payload && data.payload.networks) { + data.payload.networks.forEach(net => { + net._agent = data.agent_name || 'Unknown'; + handleStreamEvent({ + type: 'network_update', + network: net + }); + }); + } + if (data.payload && data.payload.clients) { + data.payload.clients.forEach(client => { + client._agent = data.agent_name || 'Unknown'; + handleStreamEvent({ + type: 'client_update', + client: client + }); + }); + } + } else { + // Local stream - tag with local + if (data.network) data.network._agent = 'Local'; + if (data.client) data.client._agent = 'Local'; + handleStreamEvent(data); + } + } catch (error) { + console.debug('[WiFiMode] Event parse error:', error); + } + }; + + eventSource.onerror = (error) => { + console.warn('[WiFiMode] Event stream error:', error); + if (isScanning) { + // Attempt to reconnect + setTimeout(() => { + if (isScanning && scanMode === 'deep') { + startEventStream(); + } + }, 3000); + } + }; + } + + function handleStreamEvent(event) { + switch (event.type) { + case 'network_update': + handleNetworkUpdate(event.network); + break; + + case 'client_update': + handleClientUpdate(event.client); + break; + + case 'probe_request': + handleProbeRequest(event.probe); + break; + + case 'hidden_revealed': + handleHiddenRevealed(event.bssid, event.revealed_essid); + break; + + case 'scan_started': + console.log('[WiFiMode] Scan started:', event); + break; + + case 'scan_stopped': + console.log('[WiFiMode] Scan stopped'); + setScanning(false); + break; + + case 'scan_error': + console.error('[WiFiMode] Scan error:', event.error); + showError(event.error); + setScanning(false); + break; + + case 'keepalive': + // Ignore keepalives + break; + + default: + console.debug('[WiFiMode] Unknown event type:', event.type); + } + } + + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + scheduleRender({ + table: true, + stats: true, + radar: true, + chart: true, + detail: selectedBssid === network.bssid, + }); + + if (onNetworkUpdate) onNetworkUpdate(network); + } + + function handleClientUpdate(client) { + clients.set(client.mac, client); + scheduleRender({ stats: true }); + + // Update client display if this client belongs to the selected network + updateClientInList(client); + + if (onClientUpdate) onClientUpdate(client); + } + + function handleProbeRequest(probe) { + probeRequests.push(probe); + if (probeRequests.length > CONFIG.maxProbes) { + probeRequests.shift(); + } + + if (onProbeRequest) onProbeRequest(probe); + } + + function handleHiddenRevealed(bssid, revealedSsid) { + const network = networks.get(bssid); + if (network) { + network.revealed_essid = revealedSsid; + network.display_name = `${revealedSsid} (revealed)`; + scheduleRender({ + table: true, + detail: selectedBssid === bssid, + }); + + // Show notification + showInfo(`Hidden SSID revealed: ${revealedSsid}`); + } + } + + // ========================================================================== + // Network Table + // ========================================================================== + + function initNetworkFilters() { + if (listenersBound.filters) return; + if (!elements.networkFilters) return; + + elements.networkFilters.addEventListener('click', (e) => { + if (e.target.matches('.wifi-filter-btn')) { + const filter = e.target.dataset.filter; + setNetworkFilter(filter); + } + }); + listenersBound.filters = true; + } + + function setNetworkFilter(filter) { + currentFilter = filter; + + // Update button states + if (elements.networkFilters) { + elements.networkFilters.querySelectorAll('.wifi-filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + } + + renderNetworks(); + } + + function initSortControls() { + if (listenersBound.sort) return; + + 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 }); + }); + }); + + listenersBound.sort = true; + } + + function scheduleRender(flags = {}) { + pendingRender.table = pendingRender.table || Boolean(flags.table); + pendingRender.stats = pendingRender.stats || Boolean(flags.stats); + pendingRender.radar = pendingRender.radar || Boolean(flags.radar); + pendingRender.chart = pendingRender.chart || Boolean(flags.chart); + pendingRender.detail = pendingRender.detail || Boolean(flags.detail); + + if (renderFramePending) return; + renderFramePending = true; + + requestAnimationFrame(() => { + renderFramePending = false; + + if (pendingRender.table) renderNetworks(); + if (pendingRender.stats) updateStats(); + if (pendingRender.radar) updateProximityRadar(); + if (pendingRender.chart) updateChannelChart(); + if (pendingRender.detail && selectedBssid) { + updateDetailPanel(selectedBssid, { refreshClients: false }); + } + + pendingRender.table = false; + pendingRender.stats = false; + pendingRender.radar = false; + pendingRender.chart = false; + pendingRender.detail = false; + }); + } + + function renderNetworks() { + if (!elements.networkList) return; + + // Filter networks + let filtered = Array.from(networks.values()); + + switch (currentFilter) { + case 'hidden': + filtered = filtered.filter(n => n.is_hidden); + break; + case 'open': + filtered = filtered.filter(n => n.security === 'Open'); + break; + case 'strong': + filtered = filtered.filter(n => n.rssi_current && n.rssi_current >= -60); + break; + case '2.4': + filtered = filtered.filter(n => n.band === '2.4GHz'); + break; + case '5': + filtered = filtered.filter(n => n.band === '5GHz'); + break; + } + + // Sort networks + filtered.sort((a, b) => { + let aVal, bVal; + + switch (currentSort.field) { + case 'rssi': + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + break; + case 'channel': + aVal = a.channel || 0; + bVal = b.channel || 0; + break; + case 'essid': + aVal = (a.essid || '').toLowerCase(); + bVal = (b.essid || '').toLowerCase(); + break; + case 'clients': + aVal = a.client_count || 0; + bVal = b.client_count || 0; + break; + default: + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + } + + if (currentSort.order === 'desc') { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + 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; + } + + // Render list + elements.networkList.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + + // 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'); + } + } + + 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' + : 'unknown'; + + // 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 == null ? 'weak' : 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 : '?'} +
+
+
+ `; + } + + function updateNetworkRow(network) { + scheduleRender({ + table: true, + detail: selectedBssid === network.bssid, + }); + } + + function selectNetwork(bssid) { + selectedBssid = bssid; + + // Update row selection + elements.networkList?.querySelectorAll('.network-row').forEach(row => { + row.classList.toggle('selected', row.dataset.bssid === bssid); + }); + + // Update detail panel + updateDetailPanel(bssid); + + // Highlight on radar + if (typeof WiFiProximityRadar !== 'undefined') { + WiFiProximityRadar.highlightNetwork(bssid); + } + } + + // ========================================================================== + // Detail Panel + // ========================================================================== + + function updateDetailPanel(bssid, options = {}) { + const { refreshClients = true } = options; + if (!elements.detailDrawer) return; + + const network = networks.get(bssid); + if (!network) { + closeDetail(); + return; + } + + // Update drawer header + if (elements.detailEssid) { + elements.detailEssid.textContent = network.display_name || network.essid || '[Hidden SSID]'; + } + if (elements.detailBssid) { + elements.detailBssid.textContent = network.bssid; + } + + // Update detail stats + if (elements.detailRssi) { + elements.detailRssi.textContent = network.rssi_current ? `${network.rssi_current} dBm` : '--'; + } + if (elements.detailChannel) { + elements.detailChannel.textContent = network.channel || '--'; + } + if (elements.detailBand) { + elements.detailBand.textContent = network.band || '--'; + } + if (elements.detailSecurity) { + elements.detailSecurity.textContent = network.security || '--'; + } + if (elements.detailCipher) { + elements.detailCipher.textContent = network.cipher || '--'; + } + if (elements.detailVendor) { + elements.detailVendor.textContent = network.vendor || 'Unknown'; + } + if (elements.detailClients) { + elements.detailClients.textContent = network.client_count || '0'; + } + if (elements.detailFirstSeen) { + elements.detailFirstSeen.textContent = formatTime(network.first_seen); + } + + // Show the drawer + elements.detailDrawer.classList.add('open'); + + // Fetch and display clients for this network + if (refreshClients) { + fetchClientsForNetwork(network.bssid); + } + } + + function closeDetail() { + selectedBssid = null; + if (elements.detailDrawer) { + elements.detailDrawer.classList.remove('open'); + } + elements.networkList?.querySelectorAll('.network-row').forEach(row => { + row.classList.remove('selected'); + }); + } + + // ========================================================================== + // Client Display + // ========================================================================== + + async function fetchClientsForNetwork(bssid) { + if (!elements.detailClientList) return; + const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); + + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); + elements.detailClientList.style.display = 'block'; + } + + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; + + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); + } else { + response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); + } + + if (!response.ok) { + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + return; + } + + const data = await response.json(); + // Handle agent response format (may be nested in 'result') + const result = isAgentMode && data.result ? data.result : data; + const clientList = result.clients || []; + + if (clientList.length > 0) { + renderClientList(clientList, bssid); + elements.detailClientList.style.display = 'block'; + } else { + const countBadge = document.getElementById('wifiClientCountBadge'); + if (countBadge) countBadge.textContent = '0'; + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } catch (error) { + console.debug('[WiFiMode] Error fetching clients:', error); + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } + + function renderClientList(clientList, bssid) { + const container = elements.detailClientList?.querySelector('.wifi-client-list'); + const countBadge = document.getElementById('wifiClientCountBadge'); + + if (!container) return; + + // Update count badge + if (countBadge) { + countBadge.textContent = clientList.length; + } + + // Render client cards + container.innerHTML = clientList.map(client => { + const rssi = client.rssi_current; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + // Format last seen time + const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--'; + + // Build probed SSIDs badges + let probesHtml = ''; + if (client.probed_ssids && client.probed_ssids.length > 0) { + const probes = client.probed_ssids.slice(0, 5); // Show max 5 + probesHtml = ` +
+ ${probes.map(ssid => `${escapeHtml(ssid)}`).join('')} + ${client.probed_ssids.length > 5 ? `+${client.probed_ssids.length - 5}` : ''} +
+ `; + } + + return ` +
+
+ ${escapeHtml(client.mac)} + ${escapeHtml(client.vendor || 'Unknown vendor')} + ${probesHtml} +
+
+ ${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'} + ${lastSeen} +
+
+ `; + }).join(''); + } + + function updateClientInList(client) { + // Check if this client belongs to the currently selected network + if (!selectedBssid || client.associated_bssid !== selectedBssid) { + return; + } + + const container = elements.detailClientList?.querySelector('.wifi-client-list'); + if (!container) return; + + const existingCard = container.querySelector(`[data-mac="${client.mac}"]`); + + if (existingCard) { + // Update existing card's RSSI and last seen + const rssiEl = existingCard.querySelector('.wifi-client-rssi'); + const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen'); + + if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) { + const rssi = client.rssi_current; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + rssiEl.textContent = rssi + ' dBm'; + rssiEl.className = 'wifi-client-rssi ' + signalClass; + } + + if (lastSeenEl && client.last_seen) { + lastSeenEl.textContent = formatTime(client.last_seen); + } + } else { + // New client for this network - re-fetch the full list + fetchClientsForNetwork(selectedBssid); + } + } + + // ========================================================================== + // Statistics + // ========================================================================== + + function updateStats() { + const networksList = Array.from(networks.values()); + + // Update counts in status bar + if (elements.networkCount) { + elements.networkCount.textContent = networks.size; + } + if (elements.clientCount) { + elements.clientCount.textContent = clients.size; + } + if (elements.hiddenCount) { + const hidden = networksList.filter(n => n.is_hidden).length; + elements.hiddenCount.textContent = hidden; + } + + // Update security counts + const securityCounts = { wpa3: 0, wpa2: 0, wep: 0, open: 0 }; + networksList.forEach(n => { + const sec = (n.security || '').toLowerCase(); + if (sec.includes('wpa3')) securityCounts.wpa3++; + else if (sec.includes('wpa2') || sec.includes('wpa')) securityCounts.wpa2++; + else if (sec.includes('wep')) securityCounts.wep++; + else if (sec === 'open' || sec === '') securityCounts.open++; + }); + + if (elements.openCount) elements.openCount.textContent = securityCounts.open; + + // Update zone summary + const zoneCounts = { immediate: 0, near: 0, far: 0 }; + networksList.forEach(n => { + const rssi = n.rssi_current; + if (rssi >= -50) zoneCounts.immediate++; + else if (rssi >= -70) zoneCounts.near++; + else zoneCounts.far++; + }); + + if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate; + if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near; + if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far; + } + + // ========================================================================== + // Proximity Radar + // ========================================================================== + + function initProximityRadar() { + if (!elements.proximityRadar) return; + + // Initialize radar component + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.init('wifiProximityRadar', { + mode: 'wifi', + size: 280, + onDeviceClick: (bssid) => selectNetwork(bssid), + }); + } + } + + function updateProximityRadar() { + if (typeof ProximityRadar === 'undefined') return; + + // Convert networks to radar-compatible format + const devices = Array.from(networks.values()).map(n => ({ + device_key: n.bssid, + device_id: n.bssid, + name: n.essid || '[Hidden]', + rssi_current: n.rssi_current, + rssi_ema: n.rssi_ema, + proximity_band: n.proximity_band, + estimated_distance_m: n.estimated_distance_m, + is_new: n.is_new, + heuristic_flags: n.heuristic_flags || [], + })); + + ProximityRadar.updateDevices(devices); + } + + // ========================================================================== + // Channel Chart + // ========================================================================== + + function initChannelChart() { + if (!elements.channelChart) return; + + // Initialize channel chart component + if (typeof ChannelChart !== 'undefined') { + ChannelChart.init('wifiChannelChart'); + } + + // Band tabs + if (elements.channelBandTabs) { + elements.channelBandTabs.addEventListener('click', (e) => { + if (e.target.matches('.channel-band-tab')) { + const band = e.target.dataset.band; + elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => { + t.classList.toggle('active', t.dataset.band === band); + }); + updateChannelChart(band); + } + }); + } + } + + function calculateChannelStats() { + // Calculate channel stats from current networks + const stats = {}; + const networksList = Array.from(networks.values()); + + // Initialize all channels + // 2.4 GHz: channels 1-13 + for (let ch = 1; ch <= 13; ch++) { + stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; + } + // 5 GHz: common channels + [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => { + stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; + }); + + // Count APs per channel + networksList.forEach(net => { + const ch = parseInt(net.channel); + if (stats[ch]) { + stats[ch].ap_count++; + stats[ch].client_count += (net.client_count || 0); + } + }); + + // Calculate utilization score (0-1) + const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count)); + Object.values(stats).forEach(s => { + s.utilization_score = s.ap_count / maxAPs; + }); + + return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); + } + + function updateChannelChart(band) { + if (typeof ChannelChart === 'undefined') return; + + // Use the currently active band tab if no band specified + if (!band) { + const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active'); + band = activeTab ? activeTab.dataset.band : '2.4'; + } + + // Recalculate channel stats from networks if needed + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Filter stats by band + const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz'; + const filteredStats = channelStats.filter(s => s.band === bandFilter); + const filteredRecs = recommendations.filter(r => r.band === bandFilter); + + ChannelChart.update(filteredStats, filteredRecs); + } + + // ========================================================================== + // Export + // ========================================================================== + + async function exportData(format) { + try { + const response = await fetch(`${CONFIG.apiBase}/export?format=${format}&type=all`); + if (!response.ok) throw new Error('Export failed'); + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `wifi_scan_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('[WiFiMode] Export error:', error); + showError('Export failed: ' + error.message); + } + } + + // ========================================================================== + // Utilities + // ========================================================================== + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function formatTime(isoString) { + if (!isoString) return '-'; + const date = new Date(isoString); + return date.toLocaleTimeString(); + } + + function showError(message) { + // Use global notification if available + if (typeof showNotification === 'function') { + showNotification('WiFi Error', message, 'error'); + } else { + console.error('[WiFiMode]', message); + } + } + + function showInfo(message) { + if (typeof showNotification === 'function') { + showNotification('WiFi', message, 'info'); + } else { + console.log('[WiFiMode]', message); + } + } + + // ========================================================================== + // Agent Handling + // ========================================================================== + + /** + * Handle agent change - refresh interfaces and optionally clear data. + * Called when user selects a different agent. + */ + function handleAgentChange() { + const currentAgentId = typeof currentAgent !== 'undefined' ? currentAgent : 'local'; + + // Check if agent actually changed + if (lastAgentId === currentAgentId) return; + + console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId); + + // Stop UI polling only - don't stop the actual scan on the agent + // The agent should continue running independently + if (isScanning) { + stopAgentDeepScanPolling(); + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + setScanning(false); + } + + // Clear existing data when switching agents (unless "Show All" is enabled) + if (!showAllAgentsMode) { + clearData(); + showInfo(`Switched to ${getCurrentAgentName()} - previous data cleared`); + } + + // Refresh capabilities for new agent + checkCapabilities(); + + // Check if new agent already has a scan running + checkScanStatus(); + + lastAgentId = currentAgentId; + } + + /** + * Clear all collected data. + */ + function clearData() { + networks.clear(); + clients.clear(); + probeRequests = []; + channelStats = []; + recommendations = []; + if (selectedBssid) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } + + /** + * Toggle "Show All Agents" mode. + * When enabled, displays combined WiFi results from all agents. + */ + function toggleShowAllAgents(enabled) { + showAllAgentsMode = enabled; + console.log('[WiFiMode] Show all agents mode:', enabled); + + if (enabled) { + // If currently scanning, switch to multi-agent stream + if (isScanning && eventSource) { + eventSource.close(); + startEventStream(); + } + showInfo('Showing WiFi networks from all agents'); + } else { + // Filter to current agent only + filterToCurrentAgent(); + } + } + + /** + * Filter networks to only show those from current agent. + */ + function filterToCurrentAgent() { + const agentName = getCurrentAgentName(); + const toRemove = []; + + networks.forEach((network, bssid) => { + if (network._agent && network._agent !== agentName) { + toRemove.push(bssid); + } + }); + + toRemove.forEach(bssid => networks.delete(bssid)); + + // Also filter clients + const clientsToRemove = []; + clients.forEach((client, mac) => { + if (client._agent && client._agent !== agentName) { + clientsToRemove.push(mac); + } + }); + clientsToRemove.forEach(mac => clients.delete(mac)); + if (selectedBssid && !networks.has(selectedBssid)) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } + + /** + * Refresh WiFi interfaces from current agent. + * Called when agent changes. + */ + async function refreshInterfaces() { + await checkCapabilities(); + } + + // ========================================================================== + // Public API + // ========================================================================== + + return { + init, + startQuickScan, + startDeepScan, + stopScan, + selectNetwork, + closeDetail, + setFilter: setNetworkFilter, + exportData, + checkCapabilities, + + // Agent handling + handleAgentChange, + clearData, + toggleShowAllAgents, + refreshInterfaces, + + // Getters + getNetworks: () => Array.from(networks.values()), + getClients: () => Array.from(clients.values()), + getProbes: () => [...probeRequests], + isScanning: () => isScanning, + getScanMode: () => scanMode, + isShowAllAgents: () => showAllAgentsMode, + + // Callbacks + onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, + onClientUpdate: (cb) => { onClientUpdate = cb; }, + onProbeRequest: (cb) => { onProbeRequest = cb; }, + + // Lifecycle + destroy, + }; + + /** + * Destroy — close SSE stream and clear polling timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } +})(); + +// Auto-initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Only init if we're in WiFi mode + if (typeof currentMode !== 'undefined' && currentMode === 'wifi') { + WiFiMode.init(); + } +}); From d1d44195c185ea721e651095fe0d5c08718cef12 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:26:47 +0000 Subject: [PATCH 06/63] feat(wifi): animated SVG proximity radar with sweep rotation Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 10 ++++++ static/js/modes/wifi.js | 76 ++++++++++++++++++++++++++--------------- templates/index.html | 45 +++++++++++++++++++++++- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index d84de78..555e661 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3855,6 +3855,16 @@ header h1 .tagline { .wifi-zone.mid .wifi-zone-count { color: var(--accent-yellow); } .wifi-zone.far .wifi-zone-count { color: var(--accent-red); } +.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); } +} + /* WiFi Analysis Panel (RIGHT) */ .wifi-analysis-panel { display: flex; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 631249a..1efabbf 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -167,7 +167,6 @@ const WiFiMode = (function() { initScanModeTabs(); initNetworkFilters(); initSortControls(); - initProximityRadar(); initChannelChart(); scheduleRender({ table: true, stats: true, radar: true, chart: true }); @@ -201,7 +200,6 @@ const WiFiMode = (function() { networkFilters: document.getElementById('wifiNetworkFilters'), // Visualizations - proximityRadar: document.getElementById('wifiProximityRadar'), channelChart: document.getElementById('wifiChannelChart'), channelBandTabs: document.getElementById('wifiChannelBandTabs'), @@ -1077,7 +1075,7 @@ const WiFiMode = (function() { if (pendingRender.table) renderNetworks(); if (pendingRender.stats) updateStats(); - if (pendingRender.radar) updateProximityRadar(); + if (pendingRender.radar) renderRadar(Array.from(networks.values())); if (pendingRender.chart) updateChannelChart(); if (pendingRender.detail && selectedBssid) { updateDetailPanel(selectedBssid, { refreshClients: false }); @@ -1506,36 +1504,58 @@ const WiFiMode = (function() { // Proximity Radar // ========================================================================== - function initProximityRadar() { - if (!elements.proximityRadar) return; - - // Initialize radar component - if (typeof ProximityRadar !== 'undefined') { - ProximityRadar.init('wifiProximityRadar', { - mode: 'wifi', - size: 280, - onDeviceClick: (bssid) => selectNetwork(bssid), - }); + // 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 updateProximityRadar() { - if (typeof ProximityRadar === 'undefined') return; + function renderRadar(networksList) { + const dotsGroup = document.getElementById('wifiRadarDots'); + if (!dotsGroup) return; - // Convert networks to radar-compatible format - const devices = Array.from(networks.values()).map(n => ({ - device_key: n.bssid, - device_id: n.bssid, - name: n.essid || '[Hidden]', - rssi_current: n.rssi_current, - rssi_ema: n.rssi_ema, - proximity_band: n.proximity_band, - estimated_distance_m: n.estimated_distance_m, - is_new: n.is_new, - heuristic_flags: n.heuristic_flags || [], - })); + const dots = []; + const zoneCounts = { immediate: 0, near: 0, far: 0 }; - ProximityRadar.updateDevices(devices); + 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; } // ========================================================================== diff --git a/templates/index.html b/templates/index.html index 8a89130..e7a4bce 100644 --- a/templates/index.html +++ b/templates/index.html @@ -876,7 +876,50 @@
Proximity Radar
-
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
0 From 4c37d39e07b7ffbf6377530f9290f147ab500f49 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:28:44 +0000 Subject: [PATCH 07/63] fix(wifi): remove duplicate zone count update from updateStats --- static/js/modes/wifi.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 1efabbf..1842ca6 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -1485,19 +1485,6 @@ const WiFiMode = (function() { }); if (elements.openCount) elements.openCount.textContent = securityCounts.open; - - // Update zone summary - const zoneCounts = { immediate: 0, near: 0, far: 0 }; - networksList.forEach(n => { - const rssi = n.rssi_current; - if (rssi >= -50) zoneCounts.immediate++; - else if (rssi >= -70) zoneCounts.near++; - else zoneCounts.far++; - }); - - if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate; - if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near; - if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far; } // ========================================================================== From 56ebdd7670f1beab1d38021d136cf65453190d68 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:31:21 +0000 Subject: [PATCH 08/63] fix(wifi): radar bssidToAngle divisor, Firefox SVG transform-origin, zone label clarity Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 1 + static/js/modes/wifi.js | 2 +- templates/index.html | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 555e661..51fddf4 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3856,6 +3856,7 @@ header h1 .tagline { .wifi-zone.far .wifi-zone-count { color: var(--accent-red); } .wifi-radar-sweep { + transform-box: view-box; transform-origin: 105px 105px; animation: wifi-radar-rotate 3s linear infinite; } diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 1842ca6..8fb44c4 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -1497,7 +1497,7 @@ const WiFiMode = (function() { for (let i = 0; i < bssid.length; i++) { hash = (hash * 31 + bssid.charCodeAt(i)) & 0xffffffff; } - return (hash >>> 0) / 0xffffffff * 2 * Math.PI; + return (hash >>> 0) / 0x100000000 * 2 * Math.PI; } function renderRadar(networksList) { diff --git a/templates/index.html b/templates/index.html index e7a4bce..fdc14be 100644 --- a/templates/index.html +++ b/templates/index.html @@ -923,7 +923,7 @@
0 - Near + Close
0 From 2fce80677a43a631b57fe7bedc6342e056f4b88b Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:32:34 +0000 Subject: [PATCH 09/63] fix(wifi): correct zone count colors (close=red, far=green) --- static/css/index.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 51fddf4..8866312 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3851,9 +3851,9 @@ header h1 .tagline { color: var(--text-dim); } -.wifi-zone.near .wifi-zone-count { color: var(--accent-green); } -.wifi-zone.mid .wifi-zone-count { color: var(--accent-yellow); } -.wifi-zone.far .wifi-zone-count { color: var(--accent-red); } +.wifi-zone.near .wifi-zone-count { color: var(--accent-red); } +.wifi-zone.mid .wifi-zone-count { color: var(--accent-amber); } +.wifi-zone.far .wifi-zone-count { color: var(--accent-green); } .wifi-radar-sweep { transform-box: view-box; From e5a0635418f68027f581c76f58f49655d6f52000 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:38:31 +0000 Subject: [PATCH 10/63] feat(wifi): channel heatmap and security ring chart Replace static channel bar chart and security dots with a scrolling 2.4 GHz channel heatmap (up to 10 scan snapshots) and an SVG donut security ring showing WPA2/WPA3/WEP/Open network distribution. --- static/css/index.css | 212 +++++++++++++++++++++++++--------------- static/js/modes/wifi.js | 178 ++++++++++++++++++++------------- templates/index.html | 57 +++++------ 3 files changed, 274 insertions(+), 173 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 8866312..fe9e6a4 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3866,100 +3866,158 @@ header h1 .tagline { to { transform: rotate(360deg); } } -/* WiFi Analysis Panel (RIGHT) */ +/* WiFi Analysis Panel */ .wifi-analysis-panel { display: flex; flex-direction: column; - gap: 10px; - min-width: 0; - overflow: hidden; -} - -.wifi-channel-section, -.wifi-security-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; - padding: 12px; + overflow: hidden; } -.wifi-channel-section { - flex: 1; -} - -.wifi-channel-section h5, -.wifi-security-section h5 { - margin: 0 0 10px 0; - color: var(--accent-cyan); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.wifi-channel-tabs { - display: flex; - gap: 4px; - margin-bottom: 10px; -} - -.channel-band-tab { - flex: 1; - padding: 6px 10px; - font-size: 10px; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-dim); - cursor: pointer; - transition: all 0.2s; -} - -.channel-band-tab:hover { - background: var(--bg-secondary); -} - -.channel-band-tab.active { - background: var(--accent-cyan); - color: var(--text-inverse); - border-color: var(--accent-cyan); -} - -.wifi-channel-chart { - min-height: 120px; - overflow-x: auto; - overflow-y: hidden; -} - -.wifi-security-stats { - display: flex; - flex-direction: column; - gap: 6px; -} - -.wifi-security-item { +.wifi-analysis-panel-header { display: flex; align-items: center; - gap: 8px; - font-size: 11px; + justify-content: space-between; + padding: 10px 12px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; } -.wifi-security-dot { - width: 10px; - height: 10px; +.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-security-item.wpa3 .wifi-security-dot { background: var(--accent-green); } -.wifi-security-item.wpa2 .wifi-security-dot { background: var(--accent-cyan); } -.wifi-security-item.wep .wifi-security-dot { background: var(--accent-orange); } -.wifi-security-item.open .wifi-security-dot { background: var(--accent-red); } - -.wifi-security-count { - margin-left: auto; - font-weight: 600; - color: var(--text-primary); +.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; } + /* WiFi Detail Drawer */ .wifi-detail-drawer { display: none; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 8fb44c4..7e28408 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -119,6 +119,7 @@ const WiFiMode = (function() { let probeRequests = []; let channelStats = []; let recommendations = []; + let channelHistory = []; // max 10 entries, each { timestamp, channels: {1:N,...,11:N} } // UI state let selectedBssid = null; @@ -167,7 +168,7 @@ const WiFiMode = (function() { initScanModeTabs(); initNetworkFilters(); initSortControls(); - initChannelChart(); + initHeatmap(); scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Check if already scanning @@ -199,9 +200,16 @@ const WiFiMode = (function() { networkList: document.getElementById('wifiNetworkList'), networkFilters: document.getElementById('wifiNetworkFilters'), - // Visualizations - channelChart: document.getElementById('wifiChannelChart'), - channelBandTabs: document.getElementById('wifiChannelBandTabs'), + // Visualizations — heatmap & security ring + 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'), // Zone summary zoneImmediate: document.getElementById('wifiZoneImmediate'), @@ -1076,7 +1084,6 @@ const WiFiMode = (function() { if (pendingRender.table) renderNetworks(); if (pendingRender.stats) updateStats(); if (pendingRender.radar) renderRadar(Array.from(networks.values())); - if (pendingRender.chart) updateChannelChart(); if (pendingRender.detail && selectedBssid) { updateDetailPanel(selectedBssid, { refreshClients: false }); } @@ -1092,6 +1099,18 @@ const WiFiMode = (function() { function renderNetworks() { if (!elements.networkList) return; + // Snapshot 2.4 GHz channel utilisation (use all networks, not filtered) + 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(); + // Filter networks let filtered = Array.from(networks.values()); @@ -1162,6 +1181,9 @@ const WiFiMode = (function() { const sel = elements.networkList.querySelector(`[data-bssid="${CSS.escape(selectedBssid)}"]`); if (sel) sel.classList.add('selected'); } + + renderHeatmap(); + renderSecurityRing(Array.from(networks.values())); } function createNetworkRow(network) { @@ -1549,81 +1571,101 @@ const WiFiMode = (function() { // Channel Chart // ========================================================================== - function initChannelChart() { - if (!elements.channelChart) return; + 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(''); + } - // Initialize channel chart component - if (typeof ChannelChart !== 'undefined') { - ChannelChart.init('wifiChannelChart'); + 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; } - // Band tabs - if (elements.channelBandTabs) { - elements.channelBandTabs.addEventListener('click', (e) => { - if (e.target.matches('.channel-band-tab')) { - const band = e.target.dataset.band; - elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => { - t.classList.toggle('active', t.dataset.band === band); - }); - updateChannelChart(band); - } + 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 calculateChannelStats() { - // Calculate channel stats from current networks - const stats = {}; - const networksList = Array.from(networks.values()); - - // Initialize all channels - // 2.4 GHz: channels 1-13 - for (let ch = 1; ch <= 13; ch++) { - stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; - } - // 5 GHz: common channels - [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => { - stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; - }); - - // Count APs per channel - networksList.forEach(net => { - const ch = parseInt(net.channel); - if (stats[ch]) { - stats[ch].ap_count++; - stats[ch].client_count += (net.client_count || 0); - } - }); - - // Calculate utilization score (0-1) - const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count)); - Object.values(stats).forEach(s => { - s.utilization_score = s.ap_count / maxAPs; - }); - - return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); + 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 updateChannelChart(band) { - if (typeof ChannelChart === 'undefined') return; + function renderSecurityRing(networksList) { + const svg = elements.securityRingSvg; + const legend = elements.securityRingLegend; + if (!svg || !legend) return; - // Use the currently active band tab if no band specified - if (!band) { - const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active'); - band = activeTab ? activeTab.dataset.band : '2.4'; - } + 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 }); - // Recalculate channel stats from networks if needed - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } + 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 }, + ]; - // Filter stats by band - const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz'; - const filteredStats = channelStats.filter(s => s.band === bandFilter); - const filteredRecs = recommendations.filter(r => r.band === bandFilter); + let offset = 0; + const arcs = segments.map(seg => { + const arcLen = (seg.count / total) * C; + const arc = ``; + offset += arcLen; + return arc; + }); - ChannelChart.update(filteredStats, filteredRecs); + svg.innerHTML = arcs.join('') + + ''; + + legend.innerHTML = segments.map(seg => ` +
+
+ ${seg.label} + ${seg.count} +
+ `).join(''); } // ========================================================================== diff --git a/templates/index.html b/templates/index.html index fdc14be..3912819 100644 --- a/templates/index.html +++ b/templates/index.html @@ -936,40 +936,41 @@
- +
-
-
Channel Analysis
-
- - -
-
+
+ Channel Heatmap +
-
-
Security Overview
-
-
- - WPA3 - 0 + + +
+
+
+ 2.4 GHz · Last 0 scans
-
- - WPA2 - 0 +
+
-
- - WEP - 0 -
-
- - Open - 0 +
+
+ Low +
+ High
+
+ + + +
+
+
+ + +
From bfbf06f5c574272d229b81702a958a9c91f10fb4 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:40:08 +0000 Subject: [PATCH 11/63] fix(wifi): render heatmap and security ring even when filter yields no networks --- static/js/modes/wifi.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 7e28408..10eb929 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -1170,6 +1170,8 @@ const WiFiMode = (function() { ? 'No networks match current filters' : (isScanning ? 'Scanning for networks...' : 'Start scanning to discover networks'); elements.networkList.innerHTML = `

${escapeHtml(message)}

`; + renderHeatmap(); + renderSecurityRing(Array.from(networks.values())); return; } From 73b227c49b4096c6102f0c77c412fcf768f47ca6 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:48:23 +0000 Subject: [PATCH 12/63] feat(wifi): network detail panel replaces slide-up drawer Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 112 ++++++++++++++++++++++---------------- static/js/modes/wifi.js | 35 ++++++++---- templates/index.html | 116 +++++++++++++++++++++------------------- 3 files changed, 152 insertions(+), 111 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index fe9e6a4..59be380 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4018,87 +4018,110 @@ header h1 .tagline { .wifi-security-ring-name { color: var(--text-dim); flex: 1; } .wifi-security-ring-count { color: var(--text-primary); font-weight: 600; } -/* WiFi Detail Drawer */ -.wifi-detail-drawer { - display: none; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - overflow: hidden; -} - -.wifi-detail-drawer.open { - display: block; -} - -.wifi-detail-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 15px; - background: var(--bg-tertiary); - border-bottom: 1px solid var(--border-color); -} - -.wifi-detail-title { +/* WiFi Detail Panel */ +.wifi-detail-inner { display: flex; flex-direction: column; - gap: 2px; + gap: 10px; + padding: 12px; + height: 100%; } +.wifi-detail-head { display: flex; flex-direction: column; gap: 3px; } + .wifi-detail-essid { font-size: 14px; font-weight: 600; color: var(--text-primary); + word-break: break-word; } .wifi-detail-bssid { - font-size: 11px; + font-size: 10px; font-family: monospace; color: var(--text-dim); } -.wifi-detail-close { - background: none; - border: none; +.wifi-detail-signal-bar { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 8px 10px; +} + +.wifi-detail-signal-labels { + display: flex; + justify-content: space-between; + font-size: 9px; color: var(--text-dim); - font-size: 20px; - cursor: pointer; - padding: 0 5px; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; } -.wifi-detail-close:hover { - color: var(--accent-red); +.wifi-detail-signal-track { + height: 6px; + background: var(--bg-elevated); + border-radius: 3px; + overflow: hidden; } -.wifi-detail-content { - padding: 15px; +.wifi-detail-signal-fill { + height: 100%; + border-radius: 3px; + background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); + transition: width 0.3s; } .wifi-detail-grid { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 15px; + grid-template-columns: 1fr 1fr; + gap: 6px; } .wifi-detail-stat { - display: flex; - flex-direction: column; - gap: 3px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 6px 8px; } .wifi-detail-stat .label { - font-size: 10px; + display: block; + font-size: 9px; color: var(--text-dim); text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; } .wifi-detail-stat .value { - font-size: 13px; - font-weight: 500; + font-size: 12px; + font-weight: 600; color: var(--text-primary); } +.wifi-detail-actions { + display: flex; + gap: 6px; + margin-top: auto; + padding-top: 4px; +} + +.wifi-detail-close-btn { + padding: 7px 12px; + font-family: inherit; + font-size: 10px; + background: var(--bg-secondary); + color: var(--text-dim); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + transition: color 0.15s; +} + +.wifi-detail-close-btn:hover { color: var(--text-primary); } + .wifi-detail-clients { margin-top: 15px; padding-top: 15px; @@ -4225,9 +4248,6 @@ header h1 .tagline { grid-column: span 1; } - .wifi-detail-grid { - grid-template-columns: repeat(2, 1fr); - } } /* Bluetooth Layout Container */ diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 10eb929..89f75bb 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -216,8 +216,8 @@ const WiFiMode = (function() { zoneNear: document.getElementById('wifiZoneNear'), zoneFar: document.getElementById('wifiZoneFar'), - // Detail drawer - detailDrawer: document.getElementById('wifiDetailDrawer'), + // Detail panel + detailSignalFill: document.getElementById('wifiDetailSignalFill'), detailEssid: document.getElementById('wifiDetailEssid'), detailBssid: document.getElementById('wifiDetailBssid'), detailRssi: document.getElementById('wifiDetailRssi'), @@ -1252,12 +1252,17 @@ const WiFiMode = (function() { function selectNetwork(bssid) { selectedBssid = bssid; - // Update row selection + // Highlight selected row elements.networkList?.querySelectorAll('.network-row').forEach(row => { row.classList.toggle('selected', row.dataset.bssid === bssid); }); - // Update detail panel + // Show detail in right panel + if (elements.heatmapView) elements.heatmapView.style.display = 'none'; + if (elements.detailView) elements.detailView.style.display = 'flex'; + if (elements.rightPanelTitle) elements.rightPanelTitle.textContent = 'Network Detail'; + if (elements.detailBackBtn) elements.detailBackBtn.style.display = 'inline-block'; + updateDetailPanel(bssid); // Highlight on radar @@ -1272,7 +1277,6 @@ const WiFiMode = (function() { function updateDetailPanel(bssid, options = {}) { const { refreshClients = true } = options; - if (!elements.detailDrawer) return; const network = networks.get(bssid); if (!network) { @@ -1280,7 +1284,7 @@ const WiFiMode = (function() { return; } - // Update drawer header + // Update detail header if (elements.detailEssid) { elements.detailEssid.textContent = network.display_name || network.essid || '[Hidden SSID]'; } @@ -1314,8 +1318,12 @@ const WiFiMode = (function() { elements.detailFirstSeen.textContent = formatTime(network.first_seen); } - // Show the drawer - elements.detailDrawer.classList.add('open'); + // Update signal bar + if (elements.detailSignalFill) { + const rssi = network.rssi_current; + const pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 80 * 100)) : 0; + elements.detailSignalFill.style.width = pct.toFixed(1) + '%'; + } // Fetch and display clients for this network if (refreshClients) { @@ -1325,12 +1333,17 @@ const WiFiMode = (function() { function closeDetail() { selectedBssid = null; - if (elements.detailDrawer) { - elements.detailDrawer.classList.remove('open'); - } + + // Deselect all rows elements.networkList?.querySelectorAll('.network-row').forEach(row => { row.classList.remove('selected'); }); + + // Restore heatmap in right panel + if (elements.detailView) elements.detailView.style.display = 'none'; + if (elements.heatmapView) elements.heatmapView.style.display = 'flex'; + if (elements.rightPanelTitle) elements.rightPanelTitle.textContent = 'Channel Heatmap'; + if (elements.detailBackBtn) elements.detailBackBtn.style.display = 'none'; } // ========================================================================== diff --git a/templates/index.html b/templates/index.html index 3912819..832e40f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -970,62 +970,70 @@ -
-
+
+
+
+
+
- -
-
-
- Network Name - 00:00:00:00:00:00 -
- - -
-
-
-
- Signal - -- +
+
+ Signal + +
+
+
+
+
+ +
+
+ Channel + +
+
+ Band + +
+
+ Security + +
+
+ Cipher + +
+
+ Clients + +
+
+ First Seen + +
+
+ Vendor + +
+
+ + + +
+ + +
-
- Channel - -- -
-
- Band - -- -
-
- Security - -- -
-
- Cipher - -- -
-
- Vendor - -- -
-
- Clients - -- -
-
- First Seen - -- -
-
-
From 7a4dbb826093705dc6e4555501b9f938adcb354d Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 26 Mar 2026 22:58:58 +0000 Subject: [PATCH 13/63] fix(wifi): remove dead chart pendingRender flag, dead radar highlight call, CSS.escape client mac Co-Authored-By: Claude Sonnet 4.6 --- static/js/modes/wifi.js | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 89f75bb..8b50095 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -130,7 +130,6 @@ const WiFiMode = (function() { table: false, stats: false, radar: false, - chart: false, detail: false, }; const listenersBound = { @@ -169,7 +168,7 @@ const WiFiMode = (function() { initNetworkFilters(); initSortControls(); initHeatmap(); - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + scheduleRender({ table: true, stats: true, radar: true }); // Check if already scanning checkScanStatus(); @@ -761,7 +760,7 @@ const WiFiMode = (function() { } // Update UI - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + scheduleRender({ table: true, stats: true, radar: true }); // Callbacks result.access_points.forEach(ap => { @@ -976,7 +975,6 @@ const WiFiMode = (function() { table: true, stats: true, radar: true, - chart: true, detail: selectedBssid === network.bssid, }); @@ -1072,7 +1070,6 @@ const WiFiMode = (function() { pendingRender.table = pendingRender.table || Boolean(flags.table); pendingRender.stats = pendingRender.stats || Boolean(flags.stats); pendingRender.radar = pendingRender.radar || Boolean(flags.radar); - pendingRender.chart = pendingRender.chart || Boolean(flags.chart); pendingRender.detail = pendingRender.detail || Boolean(flags.detail); if (renderFramePending) return; @@ -1091,7 +1088,6 @@ const WiFiMode = (function() { pendingRender.table = false; pendingRender.stats = false; pendingRender.radar = false; - pendingRender.chart = false; pendingRender.detail = false; }); } @@ -1264,11 +1260,6 @@ const WiFiMode = (function() { if (elements.detailBackBtn) elements.detailBackBtn.style.display = 'inline-block'; updateDetailPanel(bssid); - - // Highlight on radar - if (typeof WiFiProximityRadar !== 'undefined') { - WiFiProximityRadar.highlightNetwork(bssid); - } } // ========================================================================== @@ -1467,7 +1458,7 @@ const WiFiMode = (function() { const container = elements.detailClientList?.querySelector('.wifi-client-list'); if (!container) return; - const existingCard = container.querySelector(`[data-mac="${client.mac}"]`); + const existingCard = container.querySelector(`[data-mac="${CSS.escape(client.mac)}"]`); if (existingCard) { // Update existing card's RSSI and last seen @@ -1799,7 +1790,7 @@ const WiFiMode = (function() { if (selectedBssid) { closeDetail(); } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + scheduleRender({ table: true, stats: true, radar: true }); } /** @@ -1849,7 +1840,7 @@ const WiFiMode = (function() { if (selectedBssid && !networks.has(selectedBssid)) { closeDetail(); } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + scheduleRender({ table: true, stats: true, radar: true }); } /** From 5ee60c5259eb0559fdd924a4b24687c36d72c3ec Mon Sep 17 00:00:00 2001 From: James Smith Date: Fri, 27 Mar 2026 16:19:16 +0000 Subject: [PATCH 14/63] Add Bluetooth UI polish design spec --- .gitignore | 1 + .../2026-03-27-bluetooth-ui-polish-design.md | 225 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md diff --git a/.gitignore b/.gitignore index 5c66355..a3e0534 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ data/subghz/captures/ # Local utility scripts reset-sdr.* +.superpowers/ diff --git a/docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md b/docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md new file mode 100644 index 0000000..b44ceff --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md @@ -0,0 +1,225 @@ +# Bluetooth UI Polish — Apply WiFi Treatment + +**Date:** 2026-03-27 +**Status:** Approved +**Scope:** Frontend only — CSS, JS, HTML. No backend changes. + +## Overview + +Apply the same visual polish introduced in the WiFi scanner redesign to the Bluetooth scanner. Three areas are updated: device rows, proximity radar, and the device list header. The signal distribution strip is retained. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Row structure | Match WiFi 2-line layout closely | Visual consistency across modes | +| Locate button placement | Remove from rows, keep in detail panel only | Rows are already information-dense; locate is a detail-panel action | +| Radar sweep | CSS animation + trailing glow arc | Matches WiFi, replaces rAF loop in ProximityRadar | +| Header additions | Scan indicator + sort controls | Matches WiFi; sort by Signal / Name / Seen / Distance | +| Signal distribution strip | Keep | Provides a useful at-a-glance signal breakdown not present in WiFi | +| Sort/filter layout | Sort group + filter group on one combined controls row | Saves vertical space vs separate rows | + +## CSS Tokens + +Reuse tokens already defined in `index.css :root` — no new tokens needed: +`--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-card`, `--border-color`, `--border-light`, `--text-primary`, `--text-secondary`, `--text-dim`, `--accent-cyan`, `--accent-cyan-dim`, `--accent-green`, `--accent-green-dim`, `--accent-red`, `--accent-red-dim`, `--accent-orange`, `--accent-amber-dim`. + +Hardcoded literals used in WiFi are reused here for consistency: +- Selected row tint: `rgba(74, 163, 255, 0.07)` +- Radar arc fill: `rgba(0, 180, 216, N)` (same as WiFi's `#00b4d8`) + +## Component Designs + +### 1. Device List Header + +**Scan indicator** — new `
` added to `.wifi-device-list-header` (right-aligned via `margin-left: auto`): +```html +
+ + IDLE +
+``` +`.bt-scan-dot` is 7×7px circle, `animation: bt-scan-pulse 1.2s ease-in-out infinite` (opacity/scale identical to WiFi). Dot hidden when idle (same as WiFi pattern). + +`setScanning(scanning)` updated — add after the existing start/stop btn toggle: +```js +const dot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot'); +const text = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text'); +if (dot) dot.style.display = scanning ? 'inline-block' : 'none'; +if (text) text.textContent = scanning ? 'SCANNING' : 'IDLE'; +``` + +### 2. Sort + Filter Controls Row + +**Replaces** the separate `.bt-device-toolbar` (search stays above) and `.bt-device-filters` divs. A new combined controls row sits between the signal strip and the search input: + +```html +
+
+ Sort + + + + +
+
+ + + + + +
+
+``` + +**Sort state:** add `let sortBy = 'rssi'` to module-level state. Sort button click handler (bound in `init()`): +```js +document.querySelectorAll('.bt-sort-btn').forEach(btn => { + btn.addEventListener('click', () => { + sortBy = btn.dataset.sort; + document.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderAllDevices(); + }); +}); +``` + +**`renderAllDevices()`** — new function that re-renders all devices in sorted order: +```js +function renderAllDevices() { + if (!deviceContainer) return; + deviceContainer.innerHTML = ''; + const sorted = [...devices.values()].sort((a, b) => { + if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100); + if (sortBy === 'name') return (a.name || 'zzz').localeCompare(b.name || 'zzz'); + if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0); + if (sortBy === 'distance') return (a.estimated_distance_m ?? 999) - (b.estimated_distance_m ?? 999); + return 0; + }); + sorted.forEach(device => renderDevice(device, false)); + applyDeviceFilter(); + highlightSelectedDevice(selectedDeviceId); +} +``` + +### 3. Device Rows + +**`createSimpleDeviceCard(device)`** is rewritten to produce WiFi-style 2-line rows. All existing data attributes and filter attributes are preserved. + +**Row HTML structure:** +```html +
+
+
+ BLE|CLASSIC + NAME or ADDRESS + +
+
+ + +
+
+
+
+
+
+
+
+
+ MANUFACTURER or — + ~Xm + −N +
+
+
+``` + +**Signal fill width:** `pct = rssi != null ? Math.max(0, Math.min(100, (rssi + 100) / 70 * 100)) : 0` (preserves existing BT formula — range is −100 to −30, not −100 to −20 as in WiFi). + +**Signal fill class thresholds** (match existing `getRssiColor` breakpoints): +- `.strong` (rssi ≥ −60): `background: linear-gradient(90deg, var(--accent-green), #88d49b)` +- `.medium` (−60 > rssi ≥ −75): `background: linear-gradient(90deg, var(--accent-green), var(--accent-orange))` +- `.weak` (rssi < −75): `background: linear-gradient(90deg, var(--accent-orange), var(--accent-red))` + +**Left border colour:** +- High-confidence tracker: `#ef4444` +- Any tracker (lower confidence): `#f97316` +- Non-tracker: result of `getRssiColor(rssi)` (unchanged) + +**Unnamed device:** Address shown in `.bt-row-name.bt-unnamed` with `color: var(--text-dim); font-style: italic`. + +**Badges moved to top-right (not top-left):** `PERSIST`, `BEACON`, `STABLE` flag badges move to `.bt-row-top-right` (before status dot), keeping top-left clean. Tracker, IRK, risk, and cluster badges remain in `.bt-row-top-left` after the name. + +**Locate button:** Removed from the row entirely. It exists only in `#btDetailContent` (already present, no change needed). + +**Selected row state:** +```css +.bt-device-row.selected { + background: rgba(74, 163, 255, 0.07); + border-left-color: var(--accent-cyan) !important; +} +``` +`highlightSelectedDevice()` is unchanged — it adds/removes `.selected` by `data-bt-device-id`. + +### 4. Proximity Radar + +**`proximity-radar.js` changes:** + +`createSVG()` — add trailing arc group and CSS sweep class: +- Replace `` with a `` containing: + - Two trailing arc `` elements (90° and 60°) with low opacity fills + - The sweep `` element +- Add `` to `` to prevent arc overflow + +`animateSweep()` — remove entirely. The CSS animation replaces it. + +**CSS in `index.css` (BT section):** +```css +.bt-radar-sweep { + transform-origin: CENTER_X px CENTER_Y px; /* 140px 140px — half of CONFIG.size */ + animation: bt-radar-rotate 3s linear infinite; +} +@keyframes bt-radar-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +`isPaused` handling: when paused, add `animation-play-state: paused` to `.bt-radar-sweep` via a class toggle instead of the rAF check. `setPaused(paused)` updated: +```js +const sweep = svg?.querySelector('.bt-radar-sweep'); +if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running'; +``` + +**Arc path geometry** (center = 140, outerRadius = center − padding = 120): +- 90° arc endpoint: `(140 + 120, 140)` → `(260, 140)` +- 60° arc endpoint: `x = 140 + 120·sin(60°) ≈ 244`, `y = 140 − 120·cos(60°) + 120 = 200` → `(244, 200)` +- Sweep line: `x1=140 y1=140 x2=140 y2=20` (straight up) + +## File Changes + +| File | Change | +|---|---| +| `templates/index.html` | BT device list header: add `#btScanIndicator`; insert new `.bt-controls-row` (sort group + filter group) between `.bt-list-signal-strip` and `.bt-device-toolbar`; remove old standalone `.bt-device-filters` div; `.bt-device-toolbar` (search input) is kept unchanged | +| `static/js/modes/bluetooth.js` | Add `sortBy` state; add `renderAllDevices()`; rewrite `createSimpleDeviceCard()` for 2-line rows (no locate button); update `setScanning()` to drive `#btScanIndicator`; bind sort button listener in `init()`; remove the `locateBtn` branch from the delegated click handler in `bindListListeners()` (no `.bt-locate-btn[data-locate-id]` elements will exist in rows) | +| `static/js/components/proximity-radar.js` | `createSVG()`: add clip path + trailing arc group + CSS class on sweep; remove `animateSweep()` function and its call; update `setPaused()` to use `animationPlayState` | +| `static/css/index.css` | BT section: add `.bt-scan-indicator`, `.bt-scan-dot` + `@keyframes bt-scan-pulse`; replace `.bt-row-main`, `.bt-row-left/right`, `.bt-rssi-*`, `.bt-row-actions` with `.bt-row-top/bottom`, `.bt-signal-*`, `.bt-row-meta`, `.bt-row-name`; add `.bt-sort-btn`, `.bt-controls-row`, `.bt-sort-group`, `.bt-filter-group`; add `.bt-radar-sweep` + `@keyframes bt-radar-rotate` | + +## Out of Scope + +- Sidebar panel (scanner config, export, baseline) +- Tracker detection panel (left column) +- Zone summary cards under radar +- Radar filter buttons (New Only / Strongest / Unapproved) and Pause button +- Device detail panel (`#btDetailContent`) — already well-structured +- Bluetooth locate mode +- Any backend / route changes From 2511227c4e8bcf97423e17b6ec7d5648c970236e Mon Sep 17 00:00:00 2001 From: James Smith Date: Fri, 27 Mar 2026 16:48:08 +0000 Subject: [PATCH 15/63] Add Bluetooth UI polish implementation plan --- .../plans/2026-03-27-bluetooth-ui-polish.md | 866 ++++++++++++++++++ 1 file changed, 866 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-27-bluetooth-ui-polish.md diff --git a/docs/superpowers/plans/2026-03-27-bluetooth-ui-polish.md b/docs/superpowers/plans/2026-03-27-bluetooth-ui-polish.md new file mode 100644 index 0000000..dccded4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-bluetooth-ui-polish.md @@ -0,0 +1,866 @@ +# Bluetooth UI Polish 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:** Apply the WiFi scanner's visual polish to the Bluetooth scanner: WiFi-style 2-line device rows, CSS animated radar sweep with trailing arc, and an enhanced device list header with scan indicator and sort controls. + +**Architecture:** Pure frontend — HTML structure in `templates/index.html`, styles in `static/css/index.css`, JS logic in `static/js/modes/bluetooth.js`, and the shared radar component `static/js/components/proximity-radar.js`. Each task is independently committable and leaves the UI functional. + +**Tech Stack:** Vanilla JS (ES6 IIFE module pattern), CSS animations, inline SVG, Flask/Jinja2 templates. + +--- + +## Spec & reference + +- **Spec:** `docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md` +- **Start the app for manual verification:** + ```bash + sudo -E venv/bin/python intercept.py + # Open http://localhost:5050/?mode=bluetooth + ``` + +## File map + +| File | What changes | +|---|---| +| `static/js/components/proximity-radar.js` | `createSVG()` — add clip path + trailing arc group + CSS class; remove `animateSweep()` and its call; update `setPaused()` | +| `static/css/index.css` | Add `.bt-radar-sweep` + `@keyframes bt-radar-rotate` (~line 4410); add `.bt-scan-indicator`, `.bt-scan-dot` (~line 4836); add `.bt-controls-row`, `.bt-sort-group`, `.bt-filter-group`, `.bt-sort-btn` (~line 4944); replace `.bt-device-row` and its children with 2-line structure (~line 5130) | +| `templates/index.html` | Add `#btScanIndicator` to header (~line 1189); insert `.bt-controls-row` between signal strip and search (~line 1228); remove old `.bt-device-filters` div (lines 1231–1237) | +| `static/js/modes/bluetooth.js` | Add `sortBy` state; add `initSortControls()`; add `renderAllDevices()`; update `initDeviceFilters()` to use new `#btFilterGroup`; update `setScanning()` to drive `#btScanIndicator`; remove `locateBtn` branch from `initListInteractions()`; rewrite `createSimpleDeviceCard()` | + +--- + +## Task 1: Proximity Radar — CSS animation + trailing glow arc + +**Files:** +- Modify: `static/js/components/proximity-radar.js` (lines 58–165) +- Modify: `static/css/index.css` (~line 4410, after `.bt-radar-panel #btProximityRadar` block) + +### Context + +`createSVG()` currently renders a `` and then calls `animateSweep()` which runs a `requestAnimationFrame` loop that mutates the line's `x2`/`y2` attributes each frame. We replace this with: +- A `` containing two trailing arc `` elements and the sweep ``, all clipped to the radar circle +- A CSS `@keyframes` rotation on `.bt-radar-sweep` (same approach as the WiFi radar's `.wifi-radar-sweep`) +- `animateSweep()` deleted entirely +- `setPaused()` updated to toggle `animationPlayState` instead of the `isPaused` flag check in `rotate()` + +**Geometry** (`CONFIG.size = 280`, so `center = 140`, `outerRadius = center − CONFIG.padding = 120`): +- Sweep line: `x1=140 y1=140 x2=140 y2=20` (pointing up from centre) +- Clip circle: `cx=140 cy=140 r=120` +- 90° trailing arc (light): `M140,140 L140,20 A120,120 0 0,1 260,140 Z` +- 60° trailing arc (denser): `M140,140 L140,20 A120,120 0 0,1 244,200 Z` + _(these match the proportional geometry used by the WiFi radar at its scale)_ + +- [ ] **Step 1: Replace `createSVG()` sweep section** + +In `proximity-radar.js`, find and replace the lines that render the sweep and call `animateSweep` (the last 20 lines of `createSVG()`, roughly lines 97–134): + +Replace: +```js + + +``` +With (inside the template literal): +```js + + + + + + + + + +``` + +Also add `` to the `` block (before the closing ``): +```js + + + +``` + +- [ ] **Step 2: Remove `animateSweep()` call and function** + +At the end of `createSVG()` (line ~133), remove: +```js + // Add sweep animation + animateSweep(); +``` + +Delete the entire `animateSweep()` function (lines 139–165): +```js + /** + * Animate the radar sweep line + */ + function animateSweep() { + ... + } +``` + +- [ ] **Step 3: Update `setPaused()` to use CSS animationPlayState** + +Replace the current `setPaused()` (line 494): +```js + function setPaused(paused) { + isPaused = paused; + } +``` +With: +```js + function setPaused(paused) { + isPaused = paused; + const sweep = svg?.querySelector('.bt-radar-sweep'); + if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running'; + } +``` + +- [ ] **Step 4: Add CSS animation to `index.css`** + +In `index.css`, find the line `.bt-radar-panel #btProximityRadar {` block (line ~4402). Add the following immediately after its closing `}` (after line ~4409): + +```css +/* Bluetooth radar — CSS sweep animation (replaces rAF loop in proximity-radar.js) */ +.bt-radar-sweep { + transform-origin: 140px 140px; + animation: bt-radar-rotate 3s linear infinite; +} + +@keyframes bt-radar-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +- [ ] **Step 5: Verify manually** + +Start the app (`sudo -E venv/bin/python intercept.py`), navigate to `/?mode=bluetooth`, and confirm: +- Radar sweep rotates continuously with a trailing blue glow arc +- Clicking "Pause" stops the rotation (the sweep group freezes) +- Clicking the filter buttons (New Only / Strongest / Unapproved) still works + +- [ ] **Step 6: Commit** + +```bash +git add static/js/components/proximity-radar.js static/css/index.css +git commit -m "feat(bluetooth): CSS animated radar sweep with trailing glow arc" +``` + +--- + +## Task 2: Device list header — scan indicator + controls row + +**Files:** +- Modify: `templates/index.html` (lines 1186–1237) +- Modify: `static/css/index.css` (~lines 4836 and 4944) + +### Context + +The header row (`wifi-device-list-header`) currently has title + count. We add a pulsing scan indicator (IDLE/SCANNING) right-aligned in that row. + +Between the signal distribution strip (`.bt-list-signal-strip`, ends ~line 1227) and the search toolbar (`.bt-device-toolbar`, line 1228) we insert a new `.bt-controls-row` with two halves: +- Left: sort buttons (Signal / Name / Seen / Dist), contained in `#btSortGroup` +- Right: filter buttons (All / New / Named / Strong / Trackers), contained in `#btFilterGroup` + +The old `.bt-device-filters` div (lines 1231–1237) is deleted entirely — filters move into the controls row. + +- [ ] **Step 1: Add scan indicator to the header** + +In `index.html`, find the `.wifi-device-list-header` block for BT (~line 1186): +```html +
+
Bluetooth Devices
+ (0) +
+``` +Replace with: +```html +
+
Bluetooth Devices
+ (0) +
+ + IDLE +
+
+``` + +- [ ] **Step 2: Insert controls row, remove old filter div** + +In `index.html`, find the `.bt-device-toolbar` and `.bt-device-filters` block (lines 1228–1237): +```html +
+ +
+
+ + + + + +
+``` +Replace with: +```html +
+
+ Sort + + + + +
+
+ + + + + +
+
+
+ +
+``` + +- [ ] **Step 3: Add scan indicator CSS** + +In `index.css`, find `.bt-list-summary {` (~line 4837). Add the following immediately before it: + +```css +/* Bluetooth scan indicator (header) */ +.bt-scan-indicator { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-dim); + letter-spacing: 0.5px; +} + +.bt-scan-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent-cyan); + animation: bt-scan-pulse 1.2s ease-in-out infinite; +} + +@keyframes bt-scan-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} + +.bt-scan-text { + font-size: 10px; + color: var(--text-dim); + letter-spacing: 0.05em; +} + +.bt-scan-text.active { + color: var(--accent-cyan); + font-weight: 600; +} +``` + +- [ ] **Step 4: Add controls row CSS** + +In `index.css`, find `.bt-device-filters {` (~line 4933). Replace the entire `.bt-device-filters` block (lines 4933–4944) and the `.bt-filter-btn` blocks (lines 4946–4969) with: + +```css +/* Bluetooth controls row: sort + filter combined */ +.bt-controls-row { + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); + flex-shrink: 0; + position: sticky; + top: 44px; + z-index: 3; +} + +.bt-sort-group { + display: flex; + align-items: center; + gap: 2px; + padding: 5px 10px; + border-right: 1px solid var(--border-color); + flex-shrink: 0; +} + +.bt-filter-group { + display: flex; + align-items: center; + gap: 3px; + padding: 5px 8px; + flex-wrap: wrap; +} + +.bt-sort-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-right: 4px; +} + +.bt-sort-btn { + background: none; + border: none; + color: var(--text-dim); + font-size: 10px; + font-family: var(--font-mono); + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + transition: color 0.15s; +} + +.bt-sort-btn:hover { color: var(--text-primary); } +.bt-sort-btn.active { color: var(--accent-cyan); background: rgba(74,163,255,0.08); } + +.bt-filter-btn { + padding: 3px 8px; + font-size: 10px; + font-family: var(--font-mono); + background: none; + border: 1px solid var(--border-color); + border-radius: 3px; + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; +} + +.bt-filter-btn:hover { + color: var(--text-primary); + border-color: var(--border-light); +} + +.bt-filter-btn.active { + color: var(--accent-cyan); + border-color: rgba(74,163,255,0.4); + background: rgba(74,163,255,0.08); +} +``` + +- [ ] **Step 5: Verify manually** + +Reload the app, navigate to `/?mode=bluetooth`. Confirm: +- Header shows "IDLE" text right-aligned (no pulsing dot yet — JS wiring is Task 3) +- Controls row appears between signal strip and search: "Sort Signal Name Seen Dist | All New Named Strong Trackers" +- Filter and sort buttons are styled and visually clickable (they don't work yet — JS is Task 3) +- Old `.bt-device-filters` div is gone + +- [ ] **Step 6: Commit** + +```bash +git add templates/index.html static/css/index.css +git commit -m "feat(bluetooth): scan indicator and sort+filter controls row in device list header" +``` + +--- + +## Task 3: JS wiring — scan indicator, sort, filter handler, locate branch cleanup + +**Files:** +- Modify: `static/js/modes/bluetooth.js` + - `setScanning()` (~line 984) + - `initDeviceFilters()` (~line 130) + - `init()` (~line 91) + - `initListInteractions()` (~line 161) + - Module-level state (~line 38) + +### Context + +Four independent changes to `bluetooth.js`: +1. `setScanning()` drives `#btScanIndicator` (dot visible + "SCANNING" text when scanning) +2. `initDeviceFilters()` targets the new `#btFilterGroup` instead of `#btDeviceFilters` +3. New `initSortControls()` + `renderAllDevices()` functions, called from `init()` +4. The `locateBtn` branch in `initListInteractions()` is removed (no locate buttons in rows) + +- [ ] **Step 1: Add `sortBy` state variable** + +In `bluetooth.js`, find the module-level state block (~line 38, near `let currentDeviceFilter = 'all'`). Add: +```js + let sortBy = 'rssi'; +``` +Place it directly after `let currentDeviceFilter = 'all';`. + +- [ ] **Step 2: Update `setScanning()` to drive the scan indicator** + +In `bluetooth.js`, find `function setScanning(scanning)` (~line 984). At the end of the function body (after the `statusDot`/`statusText` block, around line 1010), add: +```js + // Drive the per-panel scan indicator + const scanDot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot'); + const scanText = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text'); + if (scanDot) scanDot.style.display = scanning ? 'inline-block' : 'none'; + if (scanText) { + scanText.textContent = scanning ? 'SCANNING' : 'IDLE'; + scanText.classList.toggle('active', scanning); + } +``` + +- [ ] **Step 3: Update `initDeviceFilters()` to use new container ID** + +In `bluetooth.js`, find `function initDeviceFilters()` (~line 130). Change: +```js + const filterContainer = document.getElementById('btDeviceFilters'); +``` +To: +```js + const filterContainer = document.getElementById('btFilterGroup'); +``` +(Everything else in the function — the click handler, search input listener — stays identical.) + +- [ ] **Step 4: Add `renderAllDevices()` function** + +In `bluetooth.js`, add the following new function after `renderDevice()` (~after line 1367): +```js + /** + * Re-render all devices in the current sort order, then re-apply the active filter. + */ + function renderAllDevices() { + if (!deviceContainer) return; + deviceContainer.innerHTML = ''; + + const sorted = [...devices.values()].sort((a, b) => { + if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100); + if (sortBy === 'name') return (a.name || '\uFFFF').localeCompare(b.name || '\uFFFF'); + if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0); + if (sortBy === 'distance') return (a.estimated_distance_m ?? 9999) - (b.estimated_distance_m ?? 9999); + return 0; + }); + + sorted.forEach(device => renderDevice(device, false)); + applyDeviceFilter(); + if (selectedDeviceId) highlightSelectedDevice(selectedDeviceId); + } +``` + +- [ ] **Step 5: Add `initSortControls()` function** + +In `bluetooth.js`, add the following new function after `initDeviceFilters()` (~after line 159): +```js + function initSortControls() { + const sortGroup = document.getElementById('btSortGroup'); + if (!sortGroup) return; + sortGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.bt-sort-btn'); + if (!btn) return; + const sort = btn.dataset.sort; + if (!sort) return; + sortBy = sort; + sortGroup.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderAllDevices(); + }); + } +``` + +- [ ] **Step 6: Call `initSortControls()` from `init()`** + +In `bluetooth.js`, find `function init()` (~line 91). After the line `initDeviceFilters();` (~line 120), add: +```js + initSortControls(); +``` + +- [ ] **Step 7: Remove `locateBtn` branch from `initListInteractions()`** + +In `bluetooth.js`, find `function initListInteractions()` (~line 161). Remove these lines from the click handler: +```js + const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); + if (locateBtn) { + event.preventDefault(); + locateById(locateBtn.dataset.locateId); + return; + } +``` +The click handler body should now go directly to: +```js + const row = event.target.closest('.bt-device-row[data-bt-device-id]'); + if (!row) return; + selectDevice(row.dataset.btDeviceId); +``` + +- [ ] **Step 8: Verify manually** + +Reload. Navigate to `/?mode=bluetooth`. Start a scan. +- Header shows pulsing dot + "SCANNING" text; stops when scan ends → "IDLE" +- Sort buttons work: clicking "Name" re-orders the device list alphabetically; "Signal" puts strongest first +- Filter buttons work: "New" shows only new devices; "Trackers" shows only trackers +- Clicking a device row still opens the detail panel + +- [ ] **Step 9: Commit** + +```bash +git add static/js/modes/bluetooth.js +git commit -m "feat(bluetooth): scan indicator, sort controls, updated filter handler" +``` + +--- + +## Task 4: Device row rewrite — WiFi-style 2-line layout + +**Files:** +- Modify: `static/js/modes/bluetooth.js` — `createSimpleDeviceCard()` (~line 1369) +- Modify: `static/css/index.css` — `.bt-device-row` block (~lines 4790, 5130–5333) + +### Context + +`createSimpleDeviceCard()` currently produces a 3-part layout (`.bt-row-main` / `.bt-row-secondary` / `.bt-row-actions`). We replace it with a 2-line WiFi-style layout: + +**Top line (`.bt-row-top`):** protocol badge + device name + tracker/IRK/risk/cluster badges (left); flag badges + status dot (right) + +**Bottom line (`.bt-row-bottom`):** full-width signal bar + flex meta row (manufacturer or address · distance · RSSI value) + +The locate button moves out of the row entirely (it exists in the detail panel, which is unchanged). + +The `.bt-status-dot.known` colour changes from green to grey (matching WiFi's "safe" colour logic — green was misleading for "known" devices). + +**CSS classes removed** (no longer emitted by JS, safe to delete): +`.bt-row-main`, `.bt-row-left`, `.bt-row-right`, `.bt-rssi-container`, `.bt-rssi-bar-bg`, `.bt-rssi-bar`, `.bt-rssi-value`, `.bt-row-secondary`, `.bt-row-actions`, `.bt-row-actions .bt-locate-btn` (and its `:hover`, `:active`, `svg` variants) + +**CSS classes added:** +`.bt-row-top`, `.bt-row-top-left`, `.bt-row-top-right`, `.bt-row-name`, `.bt-unnamed`, `.bt-signal-bar-wrap`, `.bt-signal-track`, `.bt-signal-fill` (+ `.strong`, `.medium`, `.weak`), `.bt-row-bottom`, `.bt-row-meta`, `.bt-row-rssi` (+ `.strong`, `.medium`, `.weak`) + +- [ ] **Step 1: Update `.bt-device-row` base CSS** + +In `index.css`, find `.bt-device-row {` (~line 5130). Replace the entire block: +```css +.bt-device-row { + display: flex; + flex-direction: column; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-left: 4px solid #666; + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 6px; + cursor: pointer; + transition: all 0.15s ease; +} +``` +With: +```css +.bt-device-row { + display: flex; + flex-direction: column; + border-left: 3px solid transparent; + padding: 9px 12px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.12s; +} +``` + +- [ ] **Step 2: Update `.bt-device-row` interactive states** + +Find `.bt-device-row:last-child`, `.bt-device-row:hover`, `.bt-device-row:focus-visible` (~lines 5143–5155). Replace all three: +```css +.bt-device-row:last-child { + border-bottom: none; +} + +.bt-device-row:hover { background: var(--bg-tertiary); } + +.bt-device-row:focus-visible { + outline: 1px solid var(--accent-cyan); + outline-offset: -1px; +} +``` + +Also find `.bt-device-row.selected` (~line 4790). Replace: +```css +.bt-device-row.selected { + background: rgba(0, 212, 255, 0.1); + border-color: var(--accent-cyan); +} +``` +With: +```css +.bt-device-row.selected { + background: rgba(74, 163, 255, 0.07); + border-left-color: var(--accent-cyan) !important; +} +``` + +- [ ] **Step 3: Remove old row-structure CSS, add new 2-line CSS** + +In `index.css`, find and delete the following blocks (lines ~5157–5333): +- `.bt-row-main { … }` +- `.bt-row-left { … }` +- `.bt-row-right { … }` +- `.bt-rssi-container { … }` +- `.bt-rssi-bar-bg { … }` +- `.bt-rssi-bar { … }` +- `.bt-rssi-value { … }` +- `.bt-row-secondary { … }` +- `.bt-row-actions { … }` +- `.bt-row-actions .bt-locate-btn { … }` (and the `:hover`, `:active`, `svg` variants) + +In their place, add: +```css +/* Bluetooth device row — 2-line WiFi-style layout */ +.bt-row-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 7px; +} + +.bt-row-top-left { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + flex: 1; + overflow: hidden; +} + +.bt-row-top-right { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.bt-row-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bt-row-name.bt-unnamed { + color: var(--text-dim); + font-style: italic; +} + +.bt-row-bottom { + display: flex; + align-items: center; + gap: 8px; +} + +.bt-signal-bar-wrap { flex: 1; } + +.bt-signal-track { + height: 4px; + background: var(--border-color); + border-radius: 2px; + overflow: hidden; +} + +.bt-signal-fill { + height: 100%; + border-radius: 2px; + transition: width 0.4s ease; +} + +.bt-signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); } +.bt-signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); } +.bt-signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); } + +.bt-row-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + font-size: 10px; + color: var(--text-dim); + white-space: nowrap; +} + +.bt-row-rssi { font-family: var(--font-mono); font-size: 10px; } +.bt-row-rssi.strong { color: var(--accent-green); } +.bt-row-rssi.medium { color: var(--accent-amber, #eab308); } +.bt-row-rssi.weak { color: var(--accent-red); } +``` + +- [ ] **Step 4: Update `.bt-status-dot.known` colour** + +Find `.bt-status-dot.known` (~line 5274). The current value is `background: #22c55e`. Change to: +```css +.bt-status-dot.known { + background: #484f58; +} +``` +(Green was misleading — "known" is neutral, not safe.) + +- [ ] **Step 5: Rewrite `createSimpleDeviceCard()`** + +In `bluetooth.js`, replace the entire body of `createSimpleDeviceCard(device)` (~lines 1369–1511) with: + +```js + function createSimpleDeviceCard(device) { + const protocol = device.protocol || 'ble'; + const rssi = device.rssi_current; + const inBaseline = device.in_baseline || false; + const isNew = !inBaseline; + const hasName = !!device.name; + const isTracker = device.is_tracker === true; + const trackerType = device.tracker_type; + const trackerConfidence = device.tracker_confidence; + const riskScore = device.risk_score || 0; + const agentName = device._agent || 'Local'; + const seenBefore = device.seen_before === true; + + // Signal bar + const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; + const fillClass = rssi == null ? 'weak' + : rssi >= -60 ? 'strong' + : rssi >= -75 ? 'medium' : 'weak'; + + const displayName = device.name || formatDeviceId(device.address); + const name = escapeHtml(displayName); + const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); + const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; + const seenCount = device.seen_count || 0; + const searchIndex = [ + displayName, device.address, device.manufacturer_name, + device.tracker_name, device.tracker_type, agentName + ].filter(Boolean).join(' ').toLowerCase(); + + // Protocol badge + const protoBadge = protocol === 'ble' + ? 'BLE' + : 'CLASSIC'; + + // Tracker badge + let trackerBadge = ''; + if (isTracker) { + const confColor = trackerConfidence === 'high' ? '#ef4444' + : trackerConfidence === 'medium' ? '#f97316' : '#eab308'; + const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' + : trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)'; + const typeLabel = trackerType === 'airtag' ? 'AirTag' + : trackerType === 'tile' ? 'Tile' + : trackerType === 'samsung_smarttag' ? 'SmartTag' + : trackerType === 'findmy_accessory' ? 'FindMy' + : trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER'; + trackerBadge = '' + typeLabel + ''; + } + + // IRK badge + const irkBadge = device.has_irk ? 'IRK' : ''; + + // Risk badge + let riskBadge = ''; + if (riskScore >= 0.3) { + const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316'; + riskBadge = '' + Math.round(riskScore * 100) + '% RISK'; + } + + // MAC cluster badge + const clusterBadge = device.mac_cluster_count > 1 + ? '' + device.mac_cluster_count + ' MACs' + : ''; + + // Flag badges (go to top-right, before status dot) + const hFlags = device.heuristic_flags || []; + let flagBadges = ''; + if (device.is_persistent || hFlags.includes('persistent')) + flagBadges += 'PERSIST'; + if (device.is_beacon_like || hFlags.includes('beacon_like')) + flagBadges += 'BEACON'; + if (device.is_strong_stable || hFlags.includes('strong_stable')) + flagBadges += 'STABLE'; + + // Status dot + let statusDot; + if (isTracker && trackerConfidence === 'high') { + statusDot = ''; + } else if (isNew) { + statusDot = ''; + } else { + statusDot = ''; + } + + // Bottom meta items + const metaLabel = mfr || addr; // already HTML-escaped above + const distM = device.estimated_distance_m; + const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : ''; + let metaHtml = '' + metaLabel + ''; + if (distStr) metaHtml += '' + distStr + ''; + metaHtml += '' + (rssi != null ? rssi : '—') + ''; + if (seenBefore) metaHtml += 'SEEN'; + if (agentName !== 'Local') + metaHtml += '' + + escapeHtml(agentName) + ''; + + // Left border colour + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' + : isTracker ? '#f97316' + : rssi != null && rssi >= -60 ? 'var(--accent-green)' + : rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)' + : 'var(--accent-red)'; + + return '
' + // Top line + + '
' + + '
' + + protoBadge + + '' + name + '' + + trackerBadge + irkBadge + riskBadge + clusterBadge + + '
' + + '
' + + flagBadges + statusDot + + '
' + + '
' + // Bottom line + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + metaHtml + '
' + + '
' + + '
'; + } +``` + +- [ ] **Step 6: Verify manually** + +Reload and start a scan. Confirm: +- Each device row has two lines: name + badges on top, signal bar + meta on bottom +- Locate button is gone from rows; still present in the detail panel (right-click a device, check the detail panel at left) +- Strong signal rows have green bar + green RSSI; medium amber; weak red +- Tracker rows have red left border; AirTag/Tile labels show +- Unnamed devices show address in italic grey +- Selecting a row highlights it in cyan; detail panel populates +- `PERSIST`, `BEACON`, `STABLE` flag badges appear top-right when set + +- [ ] **Step 7: Run backend tests to confirm no regressions** + +```bash +pytest tests/test_bluetooth.py tests/test_bluetooth_api.py -v +``` +Expected: all pass (frontend-only change, backend untouched). + +- [ ] **Step 8: Commit** + +```bash +git add static/js/modes/bluetooth.js static/css/index.css +git commit -m "feat(bluetooth): WiFi-style 2-line device rows" +``` From d45b8bc2fb568c0bc2ad1af7d632b9da192c8e11 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 29 Mar 2026 14:35:45 +0100 Subject: [PATCH 16/63] feat(bluetooth): CSS animated radar sweep with trailing glow arc Replaces the requestAnimationFrame loop in proximity-radar.js with a CSS @keyframes rotation on .bt-radar-sweep, mirroring the WiFi radar pattern. Adds two trailing arc paths for a glow effect and updates setPaused() to toggle animationPlayState instead of the rAF flag. Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 11 ++++++ static/js/components/proximity-radar.js | 51 +++++++------------------ 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 59be380..82b2464 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4408,6 +4408,17 @@ header h1 .tagline { justify-content: center; } +/* Bluetooth radar — CSS sweep animation (replaces rAF loop in proximity-radar.js) */ +.bt-radar-sweep { + transform-origin: 140px 140px; + animation: bt-radar-rotate 3s linear infinite; +} + +@keyframes bt-radar-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + /* Bluetooth Device Detail Panel */ .bt-detail-panel { background: var(--bg-tertiary); diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index c08f338..7f71cbd 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -73,6 +73,9 @@ const ProximityRadar = (function() { + + +
@@ -94,10 +97,15 @@ const ProximityRadar = (function() { }).join('')}
- - + + + + + + Date: Sun, 29 Mar 2026 15:25:15 +0100 Subject: [PATCH 17/63] feat(bluetooth): scan indicator and sort+filter controls row in device list header Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 104 ++++++++++++++++++++++++++++++++++++------- templates/index.html | 27 ++++++++--- 2 files changed, 109 insertions(+), 22 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 82b2464..4f3ee31 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4845,6 +4845,41 @@ header h1 .tagline { font-weight: 600; } +/* Bluetooth scan indicator (header) */ +.bt-scan-indicator { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-dim); + letter-spacing: 0.5px; +} + +.bt-scan-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent-cyan); + animation: bt-scan-pulse 1.2s ease-in-out infinite; +} + +@keyframes bt-scan-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} + +.bt-scan-text { + font-size: 10px; + color: var(--text-dim); + letter-spacing: 0.05em; +} + +.bt-scan-text.active { + color: var(--accent-cyan); + font-weight: 600; +} + .bt-list-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -4940,40 +4975,79 @@ header h1 .tagline { border-color: var(--accent-cyan); } -/* Bluetooth Device Filters */ -.bt-device-filters { +/* Bluetooth controls row: sort + filter combined */ +.bt-controls-row { display: flex; - gap: 6px; - padding: 8px 12px; + align-items: stretch; border-bottom: 1px solid var(--border-color); - flex-wrap: wrap; - flex-shrink: 0; background: var(--bg-primary); + flex-shrink: 0; position: sticky; top: 44px; z-index: 3; } +.bt-sort-group { + display: flex; + align-items: center; + gap: 2px; + padding: 5px 10px; + border-right: 1px solid var(--border-color); + flex-shrink: 0; +} + +.bt-filter-group { + display: flex; + align-items: center; + gap: 3px; + padding: 5px 8px; + flex-wrap: wrap; +} + +.bt-sort-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-right: 4px; +} + +.bt-sort-btn { + background: none; + border: none; + color: var(--text-dim); + font-size: 10px; + font-family: var(--font-mono); + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + transition: color 0.15s; +} + +.bt-sort-btn:hover { color: var(--text-primary); } +.bt-sort-btn.active { color: var(--accent-cyan); background: rgba(74,163,255,0.08); } + .bt-filter-btn { - padding: 5px 12px; - font-size: 11px; - background: var(--bg-tertiary); + padding: 3px 8px; + font-size: 10px; + font-family: var(--font-mono); + background: none; border: 1px solid var(--border-color); - border-radius: 4px; + border-radius: 3px; color: var(--text-dim); cursor: pointer; - transition: all 0.2s ease; + transition: all 0.15s; } .bt-filter-btn:hover { - background: var(--bg-secondary); color: var(--text-primary); + border-color: var(--border-light); } .bt-filter-btn.active { - background: var(--accent-purple); - border-color: var(--accent-purple); - color: var(--text-inverse); + color: var(--accent-cyan); + border-color: rgba(74,163,255,0.4); + background: rgba(74,163,255,0.08); } .bt-tracker-item { diff --git a/templates/index.html b/templates/index.html index 832e40f..8336d01 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1186,6 +1186,10 @@
Bluetooth Devices
(0) +
+ + IDLE +
@@ -1225,16 +1229,25 @@
+
+
+ Sort + + + + +
+
+ + + + + +
+
-
- - - - - -
Start scanning to discover Bluetooth devices
From ab4745c70a923b82cd9e8f2691da2c88259fcad9 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 29 Mar 2026 15:29:07 +0100 Subject: [PATCH 18/63] fix(bluetooth): update filter container ID to btFilterGroup, document sticky offset --- static/css/index.css | 1 + static/js/modes/bluetooth.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/static/css/index.css b/static/css/index.css index 4f3ee31..13c0e01 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4983,6 +4983,7 @@ header h1 .tagline { background: var(--bg-primary); flex-shrink: 0; position: sticky; + /* 44px = height of the sticky .wifi-device-list-header above this row */ top: 44px; z-index: 3; } diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index da54180..c5dc077 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -129,7 +129,7 @@ const BluetoothMode = (function() { */ function initDeviceFilters() { if (filterListenersBound) return; - const filterContainer = document.getElementById('btDeviceFilters'); + const filterContainer = document.getElementById('btFilterGroup'); if (filterContainer) { filterContainer.addEventListener('click', (e) => { const btn = e.target.closest('.bt-filter-btn'); From 6967a44620b02c3fc3e156b45b775b0bed8ad65d Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 29 Mar 2026 15:31:02 +0100 Subject: [PATCH 19/63] feat(bluetooth): scan indicator JS, sort controls, renderAllDevices Co-Authored-By: Claude Sonnet 4.6 --- static/js/modes/bluetooth.js | 53 +++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index c5dc077..1ce4315 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -36,6 +36,7 @@ const BluetoothMode = (function() { // Device list filter let currentDeviceFilter = 'all'; + let sortBy = 'rssi'; let currentSearchTerm = ''; let visibleDeviceCount = 0; let pendingDeviceFlush = false; @@ -118,6 +119,7 @@ const BluetoothMode = (function() { // Initialize device list filters initDeviceFilters(); + initSortControls(); initListInteractions(); // Set initial panel states @@ -158,17 +160,25 @@ const BluetoothMode = (function() { filterListenersBound = true; } + function initSortControls() { + const sortGroup = document.getElementById('btSortGroup'); + if (!sortGroup) return; + sortGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.bt-sort-btn'); + if (!btn) return; + const sort = btn.dataset.sort; + if (!sort) return; + sortBy = sort; + sortGroup.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderAllDevices(); + }); + } + function initListInteractions() { if (listListenersBound) return; if (deviceContainer) { deviceContainer.addEventListener('click', (event) => { - const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); - if (locateBtn) { - event.preventDefault(); - locateById(locateBtn.dataset.locateId); - return; - } - const row = event.target.closest('.bt-device-row[data-bt-device-id]'); if (!row) return; selectDevice(row.dataset.btDeviceId); @@ -1008,6 +1018,15 @@ const BluetoothMode = (function() { const statusText = document.getElementById('statusText'); if (statusDot) statusDot.classList.toggle('running', scanning); if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle'; + + // Drive the per-panel scan indicator + const scanDot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot'); + const scanText = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text'); + if (scanDot) scanDot.style.display = scanning ? 'inline-block' : 'none'; + if (scanText) { + scanText.textContent = scanning ? 'SCANNING' : 'IDLE'; + scanText.classList.toggle('active', scanning); + } } function resetStats() { @@ -1366,6 +1385,26 @@ const BluetoothMode = (function() { } } + /** + * Re-render all devices in the current sort order, then re-apply the active filter. + */ + function renderAllDevices() { + if (!deviceContainer) return; + deviceContainer.innerHTML = ''; + + const sorted = [...devices.values()].sort((a, b) => { + if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100); + if (sortBy === 'name') return (a.name || '\uFFFF').localeCompare(b.name || '\uFFFF'); + if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0); + if (sortBy === 'distance') return (a.estimated_distance_m ?? 9999) - (b.estimated_distance_m ?? 9999); + return 0; + }); + + sorted.forEach(device => renderDevice(device, false)); + applyDeviceFilter(); + if (selectedDeviceId) highlightSelectedDevice(selectedDeviceId); + } + function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; const rssi = device.rssi_current; From 71e559930076be56914bfff255e3bc6c205f54f2 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 29 Mar 2026 16:07:51 +0100 Subject: [PATCH 20/63] feat(bluetooth): WiFi-style 2-line device rows Co-Authored-By: Claude Sonnet 4.6 --- static/css/index.css | 213 ++++++++++++++--------------------- static/js/modes/bluetooth.js | 185 +++++++++++++++--------------- 2 files changed, 175 insertions(+), 223 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 13c0e01..44fcfa1 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4799,8 +4799,8 @@ header h1 .tagline { /* Selected device highlight */ .bt-device-row.selected { - background: rgba(0, 212, 255, 0.1); - border-color: var(--accent-cyan); + background: rgba(74, 163, 255, 0.07); + border-left-color: var(--accent-cyan) !important; } .bt-device-list { @@ -5212,57 +5212,107 @@ header h1 .tagline { background: linear-gradient(90deg, #ef4444, #dc2626); } -/* Bluetooth Device Row - Compact Design */ +/* Bluetooth Device Row - WiFi-style 2-line layout */ .bt-device-row { display: flex; flex-direction: column; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-left: 4px solid #666; - border-radius: 6px; - padding: 10px 12px; - margin-bottom: 6px; + border-left: 3px solid transparent; + padding: 9px 12px; cursor: pointer; - transition: all 0.15s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.12s; } .bt-device-row:last-child { - margin-bottom: 0; + border-bottom: none; } -.bt-device-row:hover { - background: rgba(0, 212, 255, 0.05); - border-color: var(--accent-cyan); -} +.bt-device-row:hover { background: var(--bg-tertiary); } .bt-device-row:focus-visible { outline: 1px solid var(--accent-cyan); - outline-offset: 1px; + outline-offset: -1px; } -.bt-row-main { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; -} - -.bt-row-left { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 8px; - min-width: 0; - flex: 1; -} - -.bt-row-right { +/* Bluetooth device row — 2-line WiFi-style layout */ +.bt-row-top { display: flex; align-items: center; - gap: 10px; + justify-content: space-between; + gap: 6px; + margin-bottom: 7px; +} + +.bt-row-top-left { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + flex: 1; + overflow: hidden; +} + +.bt-row-top-right { + display: flex; + align-items: center; + gap: 4px; flex-shrink: 0; } +.bt-row-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bt-row-name.bt-unnamed { + color: var(--text-dim); + font-style: italic; +} + +.bt-row-bottom { + display: flex; + align-items: center; + gap: 8px; +} + +.bt-signal-bar-wrap { flex: 1; } + +.bt-signal-track { + height: 4px; + background: var(--border-color); + border-radius: 2px; + overflow: hidden; +} + +.bt-signal-fill { + height: 100%; + border-radius: 2px; + transition: width 0.4s ease; +} + +.bt-signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); } +.bt-signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); } +.bt-signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); } + +.bt-row-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + font-size: 10px; + color: var(--text-dim); + white-space: nowrap; +} + +.bt-row-rssi { font-family: var(--font-mono); font-size: 10px; } +.bt-row-rssi.strong { color: var(--accent-green); } +.bt-row-rssi.medium { color: var(--accent-amber, #eab308); } +.bt-row-rssi.weak { color: var(--accent-red); } + .bt-proto-badge { display: inline-block; padding: 2px 6px; @@ -5308,43 +5358,6 @@ header h1 .tagline { border: 1px solid rgba(168, 85, 247, 0.3); } -.bt-device-name { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - overflow-wrap: break-word; - word-break: break-word; - min-width: 0; -} - -.bt-rssi-container { - display: flex; - align-items: center; - gap: 6px; -} - -.bt-rssi-bar-bg { - width: 50px; - height: 8px; - background: var(--bg-secondary); - border-radius: 4px; - overflow: hidden; -} - -.bt-rssi-bar { - height: 100%; - border-radius: 4px; - transition: width 0.3s ease; -} - -.bt-rssi-value { - font-family: var(--font-mono); - font-size: 11px; - font-weight: 600; - min-width: 28px; - text-align: right; -} - .bt-status-dot { width: 8px; height: 8px; @@ -5358,63 +5371,7 @@ header h1 .tagline { } .bt-status-dot.known { - background: #22c55e; -} - -.bt-row-secondary { - font-size: 10px; - color: var(--text-dim); - margin-top: 4px; - padding-left: 42px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.bt-row-actions { - display: flex; - justify-content: flex-end; - padding: 4px 4px 0 42px; -} - -/* Locate action on Bluetooth device rows (must be in index.css so it styles in scanner mode) */ -.bt-row-actions .bt-locate-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-height: 28px; - padding: 5px 10px; - font-size: 10px; - line-height: 1; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--accent-green, #38c180); - background: linear-gradient(180deg, rgba(56, 193, 128, 0.2), rgba(56, 193, 128, 0.12)); - border: 1px solid rgba(56, 193, 128, 0.42); - border-radius: 999px; - cursor: pointer; - white-space: nowrap; - transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; -} - -.bt-row-actions .bt-locate-btn:hover { - background: linear-gradient(180deg, rgba(56, 193, 128, 0.28), rgba(56, 193, 128, 0.18)); - border-color: rgba(56, 193, 128, 0.72); - box-shadow: 0 0 0 1px rgba(56, 193, 128, 0.2), 0 6px 16px rgba(20, 80, 54, 0.35); - transform: translateY(-1px); -} - -.bt-row-actions .bt-locate-btn:active { - transform: translateY(0); -} - -.bt-row-actions .bt-locate-btn svg { - width: 12px; - height: 12px; - stroke: currentColor; - flex-shrink: 0; + background: #484f58; } .bt-device-filter-state { diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 1ce4315..cd7c563 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -37,6 +37,7 @@ const BluetoothMode = (function() { // Device list filter let currentDeviceFilter = 'all'; let sortBy = 'rssi'; + let sortListenersBound = false; let currentSearchTerm = ''; let visibleDeviceCount = 0; let pendingDeviceFlush = false; @@ -161,6 +162,8 @@ const BluetoothMode = (function() { } function initSortControls() { + if (sortListenersBound) return; + sortListenersBound = true; const sortGroup = document.getElementById('btSortGroup'); if (!sortGroup) return; sortGroup.addEventListener('click', (e) => { @@ -1390,6 +1393,7 @@ const BluetoothMode = (function() { */ function renderAllDevices() { if (!deviceContainer) return; + if (devices.size === 0) return; deviceContainer.innerHTML = ''; const sorted = [...devices.values()].sort((a, b) => { @@ -1408,7 +1412,6 @@ const BluetoothMode = (function() { function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; const rssi = device.rssi_current; - const rssiColor = getRssiColor(rssi); const inBaseline = device.in_baseline || false; const isNew = !inBaseline; const hasName = !!device.name; @@ -1419,58 +1422,69 @@ const BluetoothMode = (function() { const agentName = device._agent || 'Local'; const seenBefore = device.seen_before === true; - // Calculate RSSI bar width (0-100%) - // RSSI typically ranges from -100 (weak) to -30 (very strong) + // Signal bar const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; + const fillClass = rssi == null ? 'weak' + : rssi >= -60 ? 'strong' + : rssi >= -75 ? 'medium' : 'weak'; const displayName = device.name || formatDeviceId(device.address); const name = escapeHtml(displayName); const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; - const seenCount = device.seen_count || 0; const searchIndex = [ - displayName, - device.address, - device.manufacturer_name, - device.tracker_name, - device.tracker_type, - agentName + displayName, device.address, device.manufacturer_name, + device.tracker_name, device.tracker_type, agentName ].filter(Boolean).join(' ').toLowerCase(); - // Protocol badge - compact + // Protocol badge const protoBadge = protocol === 'ble' ? 'BLE' : 'CLASSIC'; - // Tracker badge - show if device is detected as tracker + // Tracker badge let trackerBadge = ''; if (isTracker) { - const confColor = trackerConfidence === 'high' ? '#ef4444' : - trackerConfidence === 'medium' ? '#f97316' : '#eab308'; - const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' : - trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)'; - const typeLabel = trackerType === 'airtag' ? 'AirTag' : - trackerType === 'tile' ? 'Tile' : - trackerType === 'samsung_smarttag' ? 'SmartTag' : - trackerType === 'findmy_accessory' ? 'FindMy' : - trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER'; - trackerBadge = '' + typeLabel + ''; + const confColor = trackerConfidence === 'high' ? '#ef4444' + : trackerConfidence === 'medium' ? '#f97316' : '#eab308'; + const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' + : trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)'; + const typeLabel = trackerType === 'airtag' ? 'AirTag' + : trackerType === 'tile' ? 'Tile' + : trackerType === 'samsung_smarttag' ? 'SmartTag' + : trackerType === 'findmy_accessory' ? 'FindMy' + : trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER'; + trackerBadge = '' + typeLabel + ''; } - // IRK badge - show if paired IRK is available - let irkBadge = ''; - if (device.has_irk) { - irkBadge = 'IRK'; - } + // IRK badge + const irkBadge = device.has_irk ? 'IRK' : ''; - // Risk badge - show if risk score is significant + // Risk badge let riskBadge = ''; if (riskScore >= 0.3) { const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316'; - riskBadge = '' + Math.round(riskScore * 100) + '% RISK'; + riskBadge = '' + Math.round(riskScore * 100) + '% RISK'; } - // Status indicator + // MAC cluster badge + const clusterBadge = device.mac_cluster_count > 1 + ? '' + device.mac_cluster_count + ' MACs' + : ''; + + // Flag badges (top-right, before status dot) + const hFlags = device.heuristic_flags || []; + let flagBadges = ''; + if (device.is_persistent || hFlags.includes('persistent')) + flagBadges += 'PERSIST'; + if (device.is_beacon_like || hFlags.includes('beacon_like')) + flagBadges += 'BEACON'; + if (device.is_strong_stable || hFlags.includes('strong_stable')) + flagBadges += 'STABLE'; + + // Status dot let statusDot; if (isTracker && trackerConfidence === 'high') { statusDot = ''; @@ -1480,74 +1494,55 @@ const BluetoothMode = (function() { statusDot = ''; } - // Distance display + // Bottom meta + const metaLabel = mfr || addr; // already HTML-escaped const distM = device.estimated_distance_m; - let distStr = ''; - if (distM != null) { - distStr = '~' + distM.toFixed(1) + 'm'; - } + const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : ''; + let metaHtml = '' + metaLabel + ''; + if (distStr) metaHtml += '' + distStr + ''; + metaHtml += '' + (rssi != null ? rssi : '—') + ''; + if (seenBefore) metaHtml += 'SEEN'; + if (agentName !== 'Local') + metaHtml += '' + + escapeHtml(agentName) + ''; - // Behavioral flag badges - const hFlags = device.heuristic_flags || []; - let flagBadges = ''; - if (device.is_persistent || hFlags.includes('persistent')) { - flagBadges += 'PERSIST'; - } - if (device.is_beacon_like || hFlags.includes('beacon_like')) { - flagBadges += 'BEACON'; - } - if (device.is_strong_stable || hFlags.includes('strong_stable')) { - flagBadges += 'STABLE'; - } + // Left border colour + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' + : isTracker ? '#f97316' + : rssi != null && rssi >= -60 ? 'var(--accent-green)' + : rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)' + : 'var(--accent-red)'; - // MAC cluster badge - let clusterBadge = ''; - if (device.mac_cluster_count > 1) { - clusterBadge = '' + device.mac_cluster_count + ' MACs'; - } - - // Build secondary info line - let secondaryParts = [addr]; - if (mfr) secondaryParts.push(mfr); - if (distStr) secondaryParts.push(distStr); - secondaryParts.push('Seen ' + seenCount + '×'); - if (seenBefore) secondaryParts.push('SEEN BEFORE'); - // Add agent name if not Local - if (agentName !== 'Local') { - secondaryParts.push('' + escapeHtml(agentName) + ''); - } - const secondaryInfo = secondaryParts.join(' · '); - - // Row border color - highlight trackers in red/orange - const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : - isTracker ? '#f97316' : rssiColor; - - return '
' + - '
' + - '
' + - protoBadge + - '' + name + '' + - trackerBadge + - irkBadge + - riskBadge + - flagBadges + - clusterBadge + - '
' + - '
' + - '
' + - '
' + - '' + (rssi != null ? rssi : '--') + '' + - '
' + - statusDot + - '
' + - '
' + - '
' + secondaryInfo + '
' + - '
' + - '' + - '
' + - '
'; + return '
' + // Top line + + '
' + + '
' + + protoBadge + + '' + name + '' + + trackerBadge + irkBadge + riskBadge + clusterBadge + + '
' + + '
' + + flagBadges + statusDot + + '
' + + '
' + // Bottom line + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + metaHtml + '
' + + '
' + + '
'; } function getRssiColor(rssi) { From 5b9d81e3a88eda70c57041c61874e01b19955cbb Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 29 Mar 2026 21:34:35 +0100 Subject: [PATCH 21/63] fix(bluetooth): add transform-box to radar sweep for Firefox, remove dead radar-sweep rule --- static/css/components/proximity-viz.css | 4 ---- static/css/index.css | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/static/css/components/proximity-viz.css b/static/css/components/proximity-viz.css index cd29378..264dc3b 100644 --- a/static/css/components/proximity-viz.css +++ b/static/css/components/proximity-viz.css @@ -41,10 +41,6 @@ } } -.radar-sweep { - transform-origin: 50% 50%; -} - /* Radar filter buttons */ .bt-radar-filter-btn { transition: all 0.2s ease; diff --git a/static/css/index.css b/static/css/index.css index 44fcfa1..9b7723b 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4410,7 +4410,8 @@ header h1 .tagline { /* Bluetooth radar — CSS sweep animation (replaces rAF loop in proximity-radar.js) */ .bt-radar-sweep { - transform-origin: 140px 140px; + transform-box: view-box; /* required for consistent SVG transform-origin in Firefox */ + transform-origin: 140px 140px; /* CONFIG.size / 2 = 140 */ animation: bt-radar-rotate 3s linear infinite; } From efb7d0ed206a17e026d182f1e93fbb121e215e13 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 5 Apr 2026 12:59:51 +0100 Subject: [PATCH 22/63] chore: add AGENTS.md and superpowers plan docs Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 178 ++ .../2026-03-19-satellite-telemetry-fixes.md | 1037 ++++++++++++ .../plans/2026-03-26-wifi-scanner-redesign.md | 1480 +++++++++++++++++ 3 files changed, 2695 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md create mode 100644 docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md 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 ` @@ -162,9 +163,9 @@
- - - + + +
Polarization RHCP
Meteor (LRPT) bandwidth~140 kHz
Meteor (LRPT) bandwidth~140 kHz
@@ -177,29 +178,29 @@ + + SatDump Documentation + + + Meteor Reception Guide + +
+
+
From f0fb97512a4e27887863bd6cd2cf8c59dcc9a843 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 5 Apr 2026 14:29:50 +0100 Subject: [PATCH 25/63] feat(adsb): expand aircraft icon types and add type to hover tooltip Adds three new icon shapes (widebody, bizjet, turboprop) to the existing set (jet, prop, helicopter, military, glider), giving 8 distinct silhouettes. Classification covers common ICAO type codes: widebodies (744, 777, A380 etc.), business jets (Citation, Gulfstream, Learjet etc.), turboprops (ATR, DH8 etc.), and light GA piston aircraft. Hover tooltip now shows aircraft type description (e.g. "Airbus A320-200") when available from the aircraft DB, in addition to callsign and altitude. Closes #201 --- templates/adsb_dashboard.html | 70 ++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 244d665..9cd1e30 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -2973,12 +2973,17 @@ sudo make install const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude); const callsign = ac.callsign || icao; const alt = ac.altitude ? ac.altitude + ' ft' : 'N/A'; + const typeLabel = ac.type_desc || ac.type_code || ''; const iconType = getAircraftIconType(ac.type_code, militaryInfo.military); const isSelected = icao === selectedIcao; const prevState = markerState[icao] || {}; const iconChanged = prevState.rotation !== rotation || prevState.color !== color || prevState.iconType !== iconType || prevState.isSelected !== isSelected; - const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt; + const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt || prevState.typeLabel !== typeLabel; + + const tooltipContent = typeLabel + ? `${callsign}
${typeLabel}
${alt}` + : `${callsign}
${alt}`; if (markers[icao]) { markers[icao].setLatLng([ac.lat, ac.lon]); @@ -2987,7 +2992,7 @@ sudo make install } if (tooltipChanged) { markers[icao].unbindTooltip(); - markers[icao].bindTooltip(`${callsign}
${alt}`, { + markers[icao].bindTooltip(tooltipContent, { permanent: false, direction: 'top', className: 'aircraft-tooltip' }); } @@ -2995,24 +3000,32 @@ sudo make install markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) }) .addTo(radarMap) .on('click', () => selectAircraft(icao, 'map')); - markers[icao].bindTooltip(`${callsign}
${alt}`, { + markers[icao].bindTooltip(tooltipContent, { permanent: false, direction: 'top', className: 'aircraft-tooltip' }); } - markerState[icao] = { rotation, color, callsign, alt, iconType, isSelected }; + markerState[icao] = { rotation, color, callsign, alt, typeLabel, iconType, isSelected }; } // Aircraft type icon SVG paths const AIRCRAFT_ICONS = { + // Widebody: wide wingspan, wide tail — 747, 777, A330, A380 etc. + widebody: 'M12 2L7 10H2v2l10 4 10-4v-2h-5L12 2zm0 14l-7 3v1h14v-1l-7-3z', + // Narrowbody / default jet — A320, B737 etc. jet: 'M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z', + // Business jet: narrow swept wings set further aft + bizjet: 'M12 2L11 11H7v2l5 2.5 5-2.5v-2h-4L12 2zm0 13l-4 2v1h8v-1l-4-2z', + // Turboprop: straight high-aspect wings, engines forward + turboprop: 'M12 2L10 8H3v2.5l9 3.5 9-3.5V8h-7L12 2zm0 13l-5 2.5v1h10v-1l-5-2.5z', helicopter: 'M12 4L10 6H8V8h1l3 8 3-8h1V6h-2L12 4zm-1 14v2H9v1h6v-1h-2v-2h-2zm7-7h-2v2h2v-2zM4 11h2v2H4v-2z', + // Light piston GA — C172, PA28 etc. prop: 'M12 3L9 8H5v2l7 6 7-6v-2h-4L12 3zm0 12l-4 2v1h8v-1l-4-2z', military: 'M12 2L7 9H3l1 3 8 6 8-6 1-3h-4L12 2zm0 14l-5 2.5V20h10v-1.5L12 16z', glider: 'M12 4L10 8H4v1.5l8 4 8-4V8h-6L12 4zm0 10l-6 2v1h12v-1l-6-2z' }; - // Determine aircraft type from type_code + // Determine aircraft icon type from ICAO type_code function getAircraftIconType(typeCode, isMilitary) { if (isMilitary) return 'military'; if (!typeCode) return 'jet'; @@ -3021,22 +3034,51 @@ sudo make install // Helicopters if (code.startsWith('H') || code.includes('HELI') || - ['R22', 'R44', 'R66', 'EC35', 'EC45', 'AS50', 'AS55', 'AS65', 'B06', 'B212', 'B412', 'S76', 'A109', 'AW139', 'AW169'].some(h => code.includes(h))) { + ['R22', 'R44', 'R66', 'EC35', 'EC45', 'AS50', 'AS55', 'AS65', 'B06', 'B212', 'B412', + 'S76', 'S92', 'A109', 'AW139', 'AW169', 'AW189', 'EC25', 'EC30', 'EC75', 'EC85', + 'MI8', 'MI17', 'MI26', 'CH47', 'UH60', 'UH72', 'NH90'].some(h => code.includes(h))) { return 'helicopter'; } - // Gliders - if (code.startsWith('G') || code.includes('GLID')) { + // Gliders / motorgliders + if (code.startsWith('G') || ['GLID', 'DG1', 'DG2', 'DG3', 'DG4', 'DG5', 'ASK', 'ASW', + 'LS4', 'LS6', 'LS8', 'DUET', 'DISC', 'NIMB', 'PUCH', 'VENT'].some(g => code.includes(g))) { return 'glider'; } - // Light props (common GA aircraft) - if (['C150', 'C152', 'C172', 'C182', 'C206', 'C208', 'C210', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'SR20', 'SR22', 'DA40', 'DA42', 'TB20', 'M20', 'BE35', 'BE36', 'BE58'].some(p => code.includes(p))) { - return 'prop'; + // Widebody jets (twin-aisle) + if (['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S', + 'B762', 'B763', 'B764', 'B772', 'B773', 'B77L', 'B77W', 'B778', 'B779', + 'B788', 'B789', 'B78X', + 'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', + 'A342', 'A343', 'A345', 'A346', 'A359', 'A35K', 'A388', + 'IL86', 'IL96', 'MD11', 'DC10', 'L101'].some(w => code.startsWith(w) || code === w)) { + return 'widebody'; } - // Turboprops - if (['ATR', 'DH8', 'DHC', 'SF34', 'J328', 'B190', 'PC12', 'TBM'].some(t => code.includes(t))) { + // Business jets + if (['C25', 'C50', 'C51', 'C52', 'C55', 'C56', 'C65', 'C68', 'C70', 'C75', + 'GLF', 'GLEX', 'G150', 'G200', 'G280', 'G450', 'G500', 'G550', 'G600', 'G650', + 'LJ2', 'LJ3', 'LJ4', 'LJ5', 'LJ6', 'LJ7', + 'F2TH', 'F900', 'F7X', 'F8X', 'DA50', + 'CL30', 'CL35', 'CL60', 'CRJ1', 'CRJ2', + 'E135', 'E145', 'PC24', 'BE40', 'HA4T', 'PRM1'].some(b => code.startsWith(b))) { + return 'bizjet'; + } + + // Turboprops (regional airliners and utility) + if (['ATR', 'DH8', 'DHC', 'SF34', 'J328', 'B190', 'PC12', 'TBM', 'C208', + 'PAY', 'BE99', 'BE9L', 'SW4', 'IL18', 'AN24', 'AN26', 'AN28', + 'F27', 'F50', 'JS31', 'JS32', 'JS41', 'MA60', 'Y12'].some(t => code.startsWith(t) || code.includes(t))) { + return 'turboprop'; + } + + // Light piston GA + if (['C150', 'C152', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337', + 'PA18', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', + 'SR20', 'SR22', 'DA40', 'DA42', 'TB20', 'TB9', + 'M20', 'BE35', 'BE36', 'BE58', 'BE60', + 'RV6', 'RV7', 'RV8', 'RV9', 'RV10'].some(p => code.startsWith(p) || code.includes(p))) { return 'prop'; } @@ -3045,7 +3087,7 @@ sudo make install function createMarkerIcon(rotation, color, iconType = 'jet', isSelected = false) { const path = AIRCRAFT_ICONS[iconType] || AIRCRAFT_ICONS.jet; - const size = iconType === 'helicopter' ? 22 : 24; + const size = iconType === 'helicopter' ? 22 : iconType === 'widebody' ? 26 : iconType === 'bizjet' ? 22 : 24; const glowColor = isSelected ? 'rgba(255,255,255,0.9)' : color; const glowSize = isSelected ? '10px' : '5px'; const trackingRing = isSelected ? From fe64dd9c93eeb639b80960dc23ae278baf4c4f4a Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 5 Apr 2026 14:40:42 +0100 Subject: [PATCH 26/63] feat(api): add sdr_claims to /health and surface /devices/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /health now includes sdr_claims: a dict mapping 'sdr_type:device_index' to the mode currently using that device (e.g. {"rtlsdr:0": "pager"}). Empty when no devices are in use. /devices/status already existed and returns the full device list with in_use/used_by per device — documented in the issue response. Closes #158 --- app.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 8e06966..cf2a90c 100644 --- a/app.py +++ b/app.py @@ -478,15 +478,15 @@ def login(): return render_template('login.html', version=VERSION) -@app.route('/') -def index() -> str: - if request.args.get('mode') == 'satellite': - return redirect(url_for('satellite.satellite_dashboard')) - - tools = { - 'rtl_fm': check_tool('rtl_fm'), - 'multimon': check_tool('multimon-ng'), - 'rtl_433': check_tool('rtl_433'), +@app.route('/') +def index() -> str: + if request.args.get('mode') == 'satellite': + return redirect(url_for('satellite.satellite_dashboard')) + + tools = { + 'rtl_fm': check_tool('rtl_fm'), + 'multimon': check_tool('multimon-ng'), + 'rtl_433': check_tool('rtl_433'), 'rtlamr': check_tool('rtlamr') } devices = [d.to_dict() for d in SDRFactory.detect_devices()] @@ -908,6 +908,7 @@ def health_check() -> Response: }, 'database': db_ok, 'sdr_devices': sdr_count, + 'sdr_claims': get_sdr_device_status(), 'rate_limiting': _has_limiter, 'processes': { 'pager': current_process is not None and (current_process.poll() is None if current_process else False), From ea80b5ebc31cf5e1abb450f8b7b0f0658c23d5ab Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 5 Apr 2026 15:46:01 +0100 Subject: [PATCH 27/63] feat(tscm): add custom frequency range option to RF sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Custom Range" sweep type that lets users specify start/end MHz instead of using a fixed preset. Useful in dense RF environments where a full or standard sweep returns too many signals and causes slowdown. UI shows start/end MHz inputs when "Custom Range" is selected. Range is validated (0 < start < end ≤ 6000 MHz) before the sweep starts. Backend threads the ranges through to _scan_rf_signals(), which already supports arbitrary frequency bands. Closes #172 --- routes/tscm/__init__.py | 8 +++++--- routes/tscm/sweep.py | 20 ++++++++++++++++++++ templates/index.html | 11 +++++++++-- templates/partials/modes/tscm.html | 16 +++++++++++++++- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/routes/tscm/__init__.py b/routes/tscm/__init__.py index 0ca12fa..81b5e21 100644 --- a/routes/tscm/__init__.py +++ b/routes/tscm/__init__.py @@ -490,6 +490,7 @@ def _start_sweep_internal( bt_interface: str = '', sdr_device: int | None = None, verbose_results: bool = False, + custom_ranges: list[dict] | None = None, ) -> dict: """Start a TSCM sweep without request context.""" global _sweep_running, _sweep_thread, _current_sweep_id @@ -532,7 +533,7 @@ def _start_sweep_internal( _sweep_thread = threading.Thread( target=_run_sweep, args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled, - wifi_interface, bt_interface, sdr_device, verbose_results), + wifi_interface, bt_interface, sdr_device, verbose_results, custom_ranges), daemon=True ) _sweep_thread.start() @@ -1127,7 +1128,8 @@ def _run_sweep( wifi_interface: str = '', bt_interface: str = '', sdr_device: int | None = None, - verbose_results: bool = False + verbose_results: bool = False, + custom_ranges: list[dict] | None = None, ) -> None: """ Run the TSCM sweep in a background thread. @@ -1504,7 +1506,7 @@ def _run_sweep( 'rf_count': len(all_rf), }) # Try RF scan even if sdr_device is None (will use device 0) - rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=preset.get('ranges')) + rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=custom_ranges or preset.get('ranges')) # If no signals and this is first RF scan, send info event if not rf_signals and last_rf_scan == 0: diff --git a/routes/tscm/sweep.py b/routes/tscm/sweep.py index b3d580b..701578b 100644 --- a/routes/tscm/sweep.py +++ b/routes/tscm/sweep.py @@ -58,6 +58,25 @@ def start_sweep(): bt_interface = data.get('bt_interface', '') sdr_device = data.get('sdr_device') + # Validate custom frequency ranges if provided + custom_ranges = None + if sweep_type == 'custom': + raw_ranges = data.get('custom_ranges') or [] + validated = [] + for rng in raw_ranges: + try: + start = float(rng.get('start', 0)) + end = float(rng.get('end', 0)) + step = float(rng.get('step', 0.1)) + if 0 < start < end <= 6000: + validated.append({'start': start, 'end': end, 'step': step, + 'name': rng.get('name') or f'{start:.0f}–{end:.0f} MHz'}) + except (TypeError, ValueError): + pass + if not validated: + return jsonify({'status': 'error', 'message': 'custom sweep requires valid start/end MHz'}), 400 + custom_ranges = validated + result = _start_sweep_internal( sweep_type=sweep_type, baseline_id=baseline_id, @@ -68,6 +87,7 @@ def start_sweep(): bt_interface=bt_interface, sdr_device=sdr_device, verbose_results=verbose_results, + custom_ranges=custom_ranges, ) http_status = result.pop('http_status', 200) return jsonify(result), http_status diff --git a/templates/index.html b/templates/index.html index 8336d01..f2ef0a5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12082,6 +12082,12 @@ async function startTscmSweep() { const sweepType = document.getElementById('tscmSweepType').value; const baselineId = document.getElementById('tscmBaselineSelect').value || null; + const customRanges = sweepType === 'custom' ? [{ + start: parseFloat(document.getElementById('tscmCustomStartMhz').value), + end: parseFloat(document.getElementById('tscmCustomEndMhz').value), + step: 0.1, + name: `Custom ${document.getElementById('tscmCustomStartMhz').value}–${document.getElementById('tscmCustomEndMhz').value} MHz` + }] : null; const wifiEnabled = document.getElementById('tscmWifiEnabled').checked; const btEnabled = document.getElementById('tscmBtEnabled').checked; const rfEnabled = document.getElementById('tscmRfEnabled').checked; @@ -12122,7 +12128,8 @@ wifi_interface: wifiInterface, bt_interface: btInterface, sdr_device: sdrDevice ? parseInt(sdrDevice) : null, - verbose_results: verboseResults + verbose_results: verboseResults, + custom_ranges: customRanges }) }); @@ -12891,7 +12898,7 @@ if (tscmSweepStartTime) { const elapsed = (Date.now() - tscmSweepStartTime) / 1000; const sweepType = document.getElementById('tscmSweepType')?.value || 'standard'; - const durations = { quick: 120, standard: 300, full: 900 }; + const durations = { quick: 120, standard: 300, full: 900, custom: 300 }; const maxDuration = durations[sweepType] || 300; const progress = Math.min(95, (elapsed / maxDuration) * 100); updateTscmProgress({ progress: Math.round(progress), phase: 'Scanning' }); diff --git a/templates/partials/modes/tscm.html b/templates/partials/modes/tscm.html index 3f6737f..c0814ac 100644 --- a/templates/partials/modes/tscm.html +++ b/templates/partials/modes/tscm.html @@ -6,14 +6,28 @@
- + +
From 592e97719bacc9c0c154d3c32bc9e01317c6c77c Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 5 Apr 2026 16:02:03 +0100 Subject: [PATCH 28/63] feat(gain): normalize gain controls across modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pager and sensor gain inputs changed from unvalidated text fields to number inputs with min/max/step constraints - ADS-B dashboard now exposes a gain input in the tracking strip; previously gain was hardcoded to 40 dB with no user control - validate_gain() ceiling raised from 50 to 102 dB to support HackRF (LNA 40 + VGA 62 = 102 dB combined) and LimeSDR (73 dB) - sdrCapabilities gain_max values corrected: HackRF 62→102, Airspy 21→45 - onSDRTypeChanged() now propagates gain_max to all mode gain inputs so HTML constraints match the selected SDR's actual range Closes #162 --- templates/adsb_dashboard.html | 4 ++++ templates/index.html | 10 ++++++++-- templates/partials/modes/pager.html | 2 +- templates/partials/modes/sensor.html | 2 +- utils/validation.py | 11 ++++++++--- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 9cd1e30..fbec6ee 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -339,6 +339,9 @@ +
@@ -2444,6 +2447,7 @@ sudo make install const requestBody = { device: adsbDevice, sdr_type: adsbSdrType, + gain: parseInt(document.getElementById('adsbGainInput')?.value || '40'), bias_t: getBiasTEnabled() }; if (remoteConfig) { diff --git a/templates/index.html b/templates/index.html index f2ef0a5..49eb83a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6095,8 +6095,8 @@ 'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 }, 'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 }, 'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 }, - 'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 }, - 'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 } + 'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 102 }, // LNA(40)+VGA(62) + 'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 45 } // LNA(15)+Mix(15)+VGA(15) }; // Current device list with SDR type info @@ -6225,6 +6225,12 @@ if (caps) { document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`; document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`; + // Update max attribute on all mode gain inputs so constraints match the SDR + const gainMax = caps.gain_max; + ['gain', 'sensorGain', 'aisGainInput', 'acarsGainInput', 'aprsStripGain', 'weatherSatGain'].forEach(id => { + const el = document.getElementById(id); + if (el) el.max = gainMax; + }); } } diff --git a/templates/partials/modes/pager.html b/templates/partials/modes/pager.html index c905b96..308d216 100644 --- a/templates/partials/modes/pager.html +++ b/templates/partials/modes/pager.html @@ -32,7 +32,7 @@

Settings

- +
diff --git a/templates/partials/modes/sensor.html b/templates/partials/modes/sensor.html index de48700..b94d7c6 100644 --- a/templates/partials/modes/sensor.html +++ b/templates/partials/modes/sensor.html @@ -18,7 +18,7 @@

Settings

- +
diff --git a/utils/validation.py b/utils/validation.py index 860cfc9..ece0b53 100644 --- a/utils/validation.py +++ b/utils/validation.py @@ -93,11 +93,16 @@ def validate_rtl_tcp_port(port: Any) -> int: def validate_gain(gain: Any) -> float: - """Validate and return gain value.""" + """Validate and return gain value. + + Accepts 0 (auto/minimum) up to 102 dB to cover multi-stage SDRs + (HackRF LNA+VGA = 40+62 = 102 dB max). RTL-SDR caps at 50 dB + internally; values above 50 are only meaningful for HackRF/LimeSDR. + """ try: gain_float = float(gain) - if not 0 <= gain_float <= 50: - raise ValueError(f"Gain must be between 0 and 50, got {gain_float}") + if not 0 <= gain_float <= 102: + raise ValueError(f"Gain must be between 0 and 102, got {gain_float}") return gain_float except (ValueError, TypeError) as e: raise ValueError(f"Invalid gain: {gain}") from e From 0210791c69bf07a42887dac79787536456102e61 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 5 Apr 2026 16:20:10 +0100 Subject: [PATCH 29/63] feat(export): AIS UDP NMEA forward and JSON export endpoints for AIS/ADS-B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIS: - New optional NMEA UDP forwarding via AIS-catcher's -u flag, configurable from the AIS sidebar (host + port). Lets OpenCPN and other NMEA tools receive live vessel data directly. All SDR builders updated. - New GET /ais/vessels endpoint — clean JSON snapshot of tracked vessels for REST integration ADS-B: - New GET /adsb/aircraft endpoint — JSON snapshot of all tracked aircraft, with optional ?icao= and ?military=true filters. Response includes a reminder that port 30003 (SBS) is already available for tools like Virtual Radar Server and OpenCPN's AIS/target plugin. Closes #90 --- routes/adsb.py | 35 +++++++++++++++++++++++++++ routes/ais.py | 40 ++++++++++++++++++++++++++++++- templates/partials/modes/ais.html | 30 ++++++++++++++++++++++- utils/sdr/airspy.py | 7 +++++- utils/sdr/base.py | 6 ++++- utils/sdr/hackrf.py | 7 +++++- utils/sdr/limesdr.py | 7 +++++- utils/sdr/rtlsdr.py | 7 +++++- utils/sdr/sdrplay.py | 7 +++++- 9 files changed, 138 insertions(+), 8 deletions(-) diff --git a/routes/adsb.py b/routes/adsb.py index cf984b5..e582df1 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -803,6 +803,41 @@ def adsb_status(): }) +@adsb_bp.route('/aircraft') +def adsb_aircraft_export(): + """Export current ADS-B aircraft data as JSON. + + Returns a snapshot of all tracked aircraft suitable for integration + with external tools. For SBS (BaseStation) format, connect directly + to port 30003 which dump1090 exposes natively. + + Query parameters: + icao: Filter to a specific ICAO hex code (optional) + military: 'true' to return only military aircraft (optional) + + Returns: + JSON with aircraft list and metadata. + """ + aircraft = dict(app_module.adsb_aircraft) + + icao_filter = request.args.get('icao', '').upper() + if icao_filter: + aircraft = {k: v for k, v in aircraft.items() if k.upper() == icao_filter} + + if request.args.get('military') == 'true': + try: + from utils.military_icao import is_military_icao + aircraft = {k: v for k, v in aircraft.items() if is_military_icao(k)} + except ImportError: + pass + + return jsonify({ + 'count': len(aircraft), + 'aircraft': list(aircraft.values()), + 'sbs_port': 30003, # dump1090 SBS stream for tools like Virtual Radar Server + }) + + @adsb_bp.route('/session') def adsb_session(): """Get ADS-B session status and uptime.""" diff --git a/routes/ais.py b/routes/ais.py index 847ef36..4d6eed2 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -408,11 +408,24 @@ def start_ais(): bias_t = data.get('bias_t', False) tcp_port = AIS_TCP_PORT + # Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110) + udp_host = data.get('udp_host') or None + udp_port = None + if udp_host: + try: + udp_port = int(data.get('udp_port', 10110)) + if not 1 <= udp_port <= 65535: + raise ValueError + except (TypeError, ValueError): + return api_error('Invalid udp_port (1-65535)', 400) + cmd = builder.build_ais_command( device=sdr_device, gain=float(gain), bias_t=bias_t, - tcp_port=tcp_port + tcp_port=tcp_port, + udp_host=udp_host, + udp_port=udp_port, ) # Use the found AIS-catcher path @@ -535,6 +548,31 @@ def get_vessel_dsc(mmsi: str): return api_success(data={'mmsi': mmsi, 'dsc_messages': matches}) +@ais_bp.route('/vessels') +def ais_vessels(): + """Export current AIS vessel data as JSON. + + Returns a snapshot of all tracked vessels suitable for integration + with external tools (OpenCPN, ship tracking apps, etc.). + + Query parameters: + mmsi: Filter to a specific MMSI (optional) + + Returns: + JSON with vessel list and metadata. + """ + vessels = dict(app_module.ais_vessels) + + mmsi_filter = request.args.get('mmsi') + if mmsi_filter: + vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)} + + return jsonify({ + 'count': len(vessels), + 'vessels': list(vessels.values()), + }) + + @ais_bp.route('/dashboard') def ais_dashboard(): """Popout AIS dashboard.""" diff --git a/templates/partials/modes/ais.html b/templates/partials/modes/ais.html index dacab19..dbc6083 100644 --- a/templates/partials/modes/ais.html +++ b/templates/partials/modes/ais.html @@ -18,6 +18,23 @@
+
+

NMEA UDP Forward

+

+ Forward NMEA 0183 sentences to an external app (e.g. OpenCPN). Leave host blank to disable. +

+
+
+ + +
+
+ + +
+
+
+

Status

@@ -110,11 +127,22 @@ function startAisTracking() { const gain = document.getElementById('aisGainInput').value || '40'; const device = document.getElementById('deviceSelect')?.value || '0'; + const udpHost = document.getElementById('aisUdpHost').value.trim(); + const udpPort = parseInt(document.getElementById('aisUdpPort').value) || 10110; + + const body = { + device, gain, + bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false, + }; + if (udpHost) { + body.udp_host = udpHost; + body.udp_port = udpPort; + } fetch('/ais/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ device, gain, bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false }) + body: JSON.stringify(body) }) .then(r => r.json()) .then(data => { diff --git a/utils/sdr/airspy.py b/utils/sdr/airspy.py index 104fef1..a146f60 100644 --- a/utils/sdr/airspy.py +++ b/utils/sdr/airspy.py @@ -161,7 +161,9 @@ class AirspyCommandBuilder(CommandBuilder): device: SDRDevice, gain: float | None = None, bias_t: bool = False, - tcp_port: int = 10110 + tcp_port: int = 10110, + udp_host: str | None = None, + udp_port: int | None = None, ) -> list[str]: """ Build AIS-catcher command for AIS vessel tracking with Airspy. @@ -184,6 +186,9 @@ class AirspyCommandBuilder(CommandBuilder): if bias_t: cmd.extend(['-gr', 'biastee', '1']) + if udp_host and udp_port: + cmd.extend(['-u', udp_host, str(udp_port)]) + return cmd def build_iq_capture_command( diff --git a/utils/sdr/base.py b/utils/sdr/base.py index 6fe012c..0ecca03 100644 --- a/utils/sdr/base.py +++ b/utils/sdr/base.py @@ -165,7 +165,9 @@ class CommandBuilder(ABC): device: SDRDevice, gain: float | None = None, bias_t: bool = False, - tcp_port: int = 10110 + tcp_port: int = 10110, + udp_host: str | None = None, + udp_port: int | None = None, ) -> list[str]: """ Build AIS decoder command for vessel tracking. @@ -175,6 +177,8 @@ class CommandBuilder(ABC): gain: Gain in dB (None for auto) bias_t: Enable bias-T power (for active antennas) tcp_port: TCP port for JSON output server + udp_host: Optional host to forward NMEA 0183 sentences via UDP + udp_port: UDP port for NMEA forwarding (required if udp_host set) Returns: Command as list of strings for subprocess diff --git a/utils/sdr/hackrf.py b/utils/sdr/hackrf.py index 9db3e74..1274ada 100644 --- a/utils/sdr/hackrf.py +++ b/utils/sdr/hackrf.py @@ -161,7 +161,9 @@ class HackRFCommandBuilder(CommandBuilder): device: SDRDevice, gain: float | None = None, bias_t: bool = False, - tcp_port: int = 10110 + tcp_port: int = 10110, + udp_host: str | None = None, + udp_port: int | None = None, ) -> list[str]: """ Build AIS-catcher command for AIS vessel tracking with HackRF. @@ -184,6 +186,9 @@ class HackRFCommandBuilder(CommandBuilder): if bias_t: cmd.extend(['-gr', 'biastee', '1']) + if udp_host and udp_port: + cmd.extend(['-u', udp_host, str(udp_port)]) + return cmd def build_iq_capture_command( diff --git a/utils/sdr/limesdr.py b/utils/sdr/limesdr.py index 5c6c1b8..7b904a6 100644 --- a/utils/sdr/limesdr.py +++ b/utils/sdr/limesdr.py @@ -140,7 +140,9 @@ class LimeSDRCommandBuilder(CommandBuilder): device: SDRDevice, gain: float | None = None, bias_t: bool = False, - tcp_port: int = 10110 + tcp_port: int = 10110, + udp_host: str | None = None, + udp_port: int | None = None, ) -> list[str]: """ Build AIS-catcher command for AIS vessel tracking with LimeSDR. @@ -161,6 +163,9 @@ class LimeSDRCommandBuilder(CommandBuilder): if gain is not None and gain > 0: cmd.extend(['-gr', 'tuner', str(int(gain))]) + if udp_host and udp_port: + cmd.extend(['-u', udp_host, str(udp_port)]) + return cmd def build_iq_capture_command( diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index 80b50e5..9437338 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -281,7 +281,9 @@ class RTLSDRCommandBuilder(CommandBuilder): device: SDRDevice, gain: float | None = None, bias_t: bool = False, - tcp_port: int = 10110 + tcp_port: int = 10110, + udp_host: str | None = None, + udp_port: int | None = None, ) -> list[str]: """ Build AIS-catcher command for AIS vessel tracking. @@ -308,6 +310,9 @@ class RTLSDRCommandBuilder(CommandBuilder): if bias_t: cmd.extend(['-gr', 'BIASTEE', 'on']) + if udp_host and udp_port: + cmd.extend(['-u', udp_host, str(udp_port)]) + return cmd def build_iq_capture_command( diff --git a/utils/sdr/sdrplay.py b/utils/sdr/sdrplay.py index 1b2c335..6b7293d 100644 --- a/utils/sdr/sdrplay.py +++ b/utils/sdr/sdrplay.py @@ -139,7 +139,9 @@ class SDRPlayCommandBuilder(CommandBuilder): device: SDRDevice, gain: float | None = None, bias_t: bool = False, - tcp_port: int = 10110 + tcp_port: int = 10110, + udp_host: str | None = None, + udp_port: int | None = None, ) -> list[str]: """ Build AIS-catcher command for AIS vessel tracking with SDRPlay. @@ -162,6 +164,9 @@ class SDRPlayCommandBuilder(CommandBuilder): if bias_t: cmd.extend(['-gr', 'biastee', '1']) + if udp_host and udp_port: + cmd.extend(['-u', udp_host, str(udp_port)]) + return cmd def build_iq_capture_command( From 1fc80b05b11aefcf0bbd0e1d812917c13b396f40 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 12 Apr 2026 21:28:50 +0100 Subject: [PATCH 30/63] fix(sensor): replace static NodeList while-loops causing page freeze and removeChild TypeError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four list-trimming loops used querySelectorAll (static NodeList) inside a while condition, so .length never decreased — causing infinite loops that froze the page, or repeated removeChild calls on already-removed nodes (TypeError: parameter 1 is not of type 'Node'). Also replaces blocking alert() with showInfo() for start errors and adds a .catch() handler to the start_sensor fetch so network failures surface cleanly instead of leaving the UI in a broken state. Co-Authored-By: Claude Sonnet 4.6 --- templates/index.html | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/templates/index.html b/templates/index.html index 49eb83a..d810531 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5341,8 +5341,11 @@ // Clear existing output output.innerHTML = ''; } else { - alert('Error: ' + data.message); + showInfo('Error: ' + (data.message || 'Failed to start sensor')); } + }) + .catch(err => { + showInfo('Error starting sensor: ' + err.message); }); } @@ -5580,8 +5583,8 @@ // Keep list manageable const cards = output.querySelectorAll('.signal-card'); - while (cards.length > 100) { - output.removeChild(output.lastChild); + for (let i = cards.length - 1; i >= 100; i--) { + cards[i].remove(); } } @@ -5864,14 +5867,8 @@ // Limit to max 50 unique meters (cards won't pile up since we update in place) const cards = output.querySelectorAll('.signal-card.meter-aggregated'); - while (cards.length > 50) { - // Remove oldest card (last one) - const oldestCard = output.querySelector('.signal-card.meter-aggregated:last-of-type'); - if (oldestCard) { - output.removeChild(oldestCard); - } else { - break; - } + for (let i = cards.length - 1; i >= 50; i--) { + cards[i].remove(); } } @@ -7055,8 +7052,8 @@ // Limit messages displayed (keep placeholder/empty-state) const cards = output.querySelectorAll('.signal-card'); - while (cards.length > 100) { - output.removeChild(cards[cards.length - 1]); + for (let i = cards.length - 1; i >= 100; i--) { + cards[i].remove(); } } @@ -7420,7 +7417,7 @@ // Limit displayed devices while (content.children.length > 50) { - content.removeChild(content.lastChild); + content.lastElementChild.remove(); } } @@ -10707,7 +10704,7 @@ // Keep log manageable while (logEl.children.length > 100) { - logEl.removeChild(logEl.lastChild); + logEl.lastElementChild.remove(); } // Update map if position data From b01598753da8f92e56782c1b9d014c8a4cce68d7 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 13 Apr 2026 13:18:50 +0100 Subject: [PATCH 31/63] fix(adsb): disable bias-T on stop and warn when toggled while running (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(adsb): disable bias-T on stop and warn when toggled while running The RTL-SDR bias-T hardware register persists after the device is closed, so toggling bias-T off in the UI and stopping the SDR had no effect on the actual hardware — verified with a multimeter in issue #205. - Add disable_bias_t_via_rtl_biast() to rtlsdr.py (mirrors enable, uses -b 0) - Track adsb_bias_t_active in adsb.py; call disable on stop_adsb() so the hardware register is cleared when ADS-B is stopped - Show an inline warning in the UI when the bias-T checkbox is toggled while any SDR mode is active, since the setting only takes effect at start time Co-Authored-By: Claude Sonnet 4.6 * fix(lint): remove unused imports in tscm sweep.py Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- routes/adsb.py | 13 +++++++++++-- routes/tscm/sweep.py | 3 --- templates/index.html | 8 ++++++++ utils/sdr/rtlsdr.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/routes/adsb.py b/routes/adsb.py index e582df1..430347b 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -79,6 +79,7 @@ adsb_bytes_received = 0 adsb_lines_received = 0 adsb_active_device = None # Track which device index is being used adsb_active_sdr_type: str | None = None +adsb_bias_t_active = False # Track if bias-t was enabled at start (for cleanup on stop) _sbs_error_logged = False # Suppress repeated connection error logs # Track ICAOs already looked up in aircraft database (avoid repeated lookups) @@ -859,7 +860,7 @@ def adsb_session(): @adsb_bp.route('/start', methods=['POST']) def start_adsb(): """Start ADS-B tracking.""" - global adsb_using_service, adsb_active_device, adsb_active_sdr_type + global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active with app_module.adsb_lock: if adsb_using_service: @@ -991,6 +992,7 @@ def start_adsb(): # Build ADS-B decoder command bias_t = data.get('bias_t', False) + adsb_bias_t_active = bias_t cmd = builder.build_adsb_command( device=sdr_device, gain=float(gain), @@ -1139,7 +1141,7 @@ def start_adsb(): @adsb_bp.route('/stop', methods=['POST']) def stop_adsb(): """Stop ADS-B tracking.""" - global adsb_using_service, adsb_active_device, adsb_active_sdr_type + global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active data = request.get_json(silent=True) or {} stop_source = data.get('source') stopped_by = request.remote_addr @@ -1162,6 +1164,13 @@ def stop_adsb(): clear_dump1090_pid() logger.info("ADS-B process stopped") + # Turn off bias-T if it was enabled at start — the hardware register + # persists after the device is closed, so we must explicitly disable it. + if adsb_bias_t_active and (adsb_active_sdr_type or 'rtlsdr') == 'rtlsdr': + from utils.sdr.rtlsdr import disable_bias_t_via_rtl_biast + disable_bias_t_via_rtl_biast(adsb_active_device or 0) + adsb_bias_t_active = False + # Release device from registry if adsb_active_device is not None: app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr') diff --git a/routes/tscm/sweep.py b/routes/tscm/sweep.py index 701578b..6dea2ee 100644 --- a/routes/tscm/sweep.py +++ b/routes/tscm/sweep.py @@ -19,12 +19,9 @@ from flask import Response, jsonify, request from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset from routes.tscm import ( _baseline_recorder, - _current_sweep_id, _emit_event, _start_sweep_internal, - _sweep_running, tscm_bp, - tscm_queue, ) from utils.database import get_tscm_sweep, update_tscm_sweep from utils.event_pipeline import process_event diff --git a/templates/index.html b/templates/index.html index d810531..249f2b4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6349,6 +6349,14 @@ function saveBiasTSetting() { const enabled = document.getElementById('biasT')?.checked || false; localStorage.setItem('biasTEnabled', enabled); + // Warn if any SDR mode is currently running — bias-T is applied at + // start time and cannot be toggled on a running device. + const anyRunning = isRunning || isSensorRunning + || (typeof isAdsbRunning !== 'undefined' && isAdsbRunning) + || (typeof isAisRunning !== 'undefined' && isAisRunning); + if (anyRunning) { + showInfo('Bias-T change will take effect after restarting the active SDR mode'); + } } function getBiasTEnabled() { diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index 9437338..51a9264 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -75,6 +75,35 @@ def enable_bias_t_via_rtl_biast(device_index: int = 0) -> bool: return False +def disable_bias_t_via_rtl_biast(device_index: int = 0) -> bool: + """Disable bias-t power using rtl_biast (RTL-SDR Blog drivers). + + Should be called when stopping an SDR mode that had bias-t enabled, + since the hardware register persists after the device is closed. + + Returns True if bias-t was disabled successfully. + """ + rtl_biast_path = get_tool_path('rtl_biast') or 'rtl_biast' + try: + result = subprocess.run( + [rtl_biast_path, '-b', '0', '-d', str(device_index)], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + logger.info(f"Bias-t disabled via rtl_biast on device {device_index}") + return True + logger.warning(f"rtl_biast failed (exit {result.returncode}): {result.stderr.strip()}") + return False + except FileNotFoundError: + logger.warning("rtl_biast not found — bias-t may remain on after stop") + return False + except Exception as e: + logger.warning(f"Failed to disable bias-t via rtl_biast: {e}") + return False + + def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None: """Detect the correct bias-t flag for the installed dump1090 variant. From 238ad7936a10a5c9386a0d2cfeb88083ea673824 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 13 Apr 2026 13:40:22 +0100 Subject: [PATCH 32/63] chore: add pre-commit hook to catch lint errors before push Adds ruff pre-commit hook that auto-fixes and formats on every commit, preventing lint CI failures from reaching GitHub. Co-Authored-By: Claude Sonnet 4.6 --- .pre-commit-config.yaml | 7 +++++++ requirements-dev.txt | 1 + 2 files changed, 8 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0d6e354 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/requirements-dev.txt b/requirements-dev.txt index 8051f92..d5c23a3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,6 +10,7 @@ pytest-mock>=3.15.1 ruff>=0.1.0 black>=23.0.0 mypy>=1.0.0 +pre-commit>=3.0.0 # Type stubs types-flask>=1.1.0 From 34fb030af19c59be6f98fa5dd060065448fdbdfd Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 13 Apr 2026 16:47:30 +0100 Subject: [PATCH 33/63] docs: add UI/UX improvements design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mission Control aesthetic, maps overhaul, mode polish sprint for 12 modes, and 5 new features (Spectrum Overview, Alerts Engine, Signal Recording, Signal ID, Mobile PWA) — decomposed into 4 sequential sub-projects. Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-13-ui-ux-improvements-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-13-ui-ux-improvements-design.md diff --git a/docs/superpowers/specs/2026-04-13-ui-ux-improvements-design.md b/docs/superpowers/specs/2026-04-13-ui-ux-improvements-design.md new file mode 100644 index 0000000..8a013ce --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-ui-ux-improvements-design.md @@ -0,0 +1,203 @@ +# INTERCEPT UI/UX Improvements — Design Spec + +**Date:** 2026-04-13 +**Status:** Approved +**Scope:** Frontend (CSS, JS, HTML) + backend stubs for new features + +--- + +## Overview + +INTERCEPT has grown to 20+ modes faster than its design system has kept pace. The goal of this initiative is to establish a "Mission Control" visual identity — tactical, ops-center, every pixel earning its place — and apply it consistently across the entire application. Work is decomposed into four sequential sub-projects, each building on the last. + +## Aesthetic Direction + +**Mission Control.** Dense, tactical, ops-center feel. Radar aesthetics, glowing blips, HUD overlays, tight data-rich layouts. Think NORAD or air traffic control. + +**Maps:** Premium dark map base (rich land/water separation, minimal labels) with tactical chrome on top — HUD corner panels, range rings, lat/lon grid, custom markers. + +**Navigation:** Keep existing sidebar structure, polish execution — density, group headers, active states, collapsible groups. + +--- + +## Sub-Project 1: Design System Uplift + Sidebar Polish + +**Goal:** Establish the Mission Control visual language as the new baseline. Every subsequent sub-project inherits from this. + +### CSS Token Changes (`static/css/core/variables.css`) + +| Token | Current | New | Reason | +|---|---|---|---| +| `--bg-primary` | `#0a0c10` | `#06080d` | Deeper black for richer depth | +| `--bg-secondary` | `#0f1218` | `#090c12` | Consistent depth step | +| New: `--accent-cyan-glow` | — | `rgba(74,158,255,0.15)` | Panel borders and active states | +| `--text-xs` | `10px` | `9px` | Tighter data density | +| `--text-sm` | `12px` | `11px` | Tighter data density | +| New: `--scanline` | — | `repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px)` | Subtle scanline texture for visual containers | + +### Sidebar (`templates/partials/nav.html`, `static/css/core/layout.css`) + +- **Group headers:** Uppercase letter-spacing treatment with a fine left-border accent line. +- **Active mode button:** Replace background fill with a 3px left-border glow (`--accent-cyan` with blur) + subtle background tint. +- **Icon sizing:** Standardise all nav button icons to a consistent size (currently varies between modes). +- **Hover state:** Replace flat highlight with `--accent-cyan-glow` background. +- **Group collapse/expand:** Each nav group gets a toggle arrow. State persisted in `localStorage`. Allows users to hide groups they don't use. + +### Global Panel Treatment (`static/css/core/components.css`) + +- Panel headers: thin gradient top-border (`--accent-cyan` → transparent) for visual hierarchy. +- Panel indicator dots: pulse animation when active (extend existing `.panel-indicator.active` rule). +- Card backgrounds: subtle inner vignette via `box-shadow: inset 0 0 40px rgba(0,0,0,0.3)`. +- Scanline texture applied to all `.visuals-container` elements via `::after` pseudo-element. + +--- + +## Sub-Project 2: Maps Overhaul + +**Goal:** Premium map base + tactical chrome. Shared utility applied to all map-using modes. + +**Affected pages:** ADS-B dashboard, AIS dashboard, Satellite dashboard, APRS, GPS, Radiosonde map. + +### Tile Upgrade + +- New default tile provider: **Stadia Maps "Alidade Smooth Dark"** — cleaner land/water separation, minimal label noise, free tier available (requires a free API key from stadiamaps.com; key stored in settings/env var `INTERCEPT_STADIA_KEY`). +- Add **"Tactical"** option in settings tile picker: near-black base, minimal labels, overlays do the visual work. +- Settings modal tile picker updated to include previews of each style. + +### Custom Markers + +Replace default Leaflet `divIcon` circles with SVG symbols: + +- **Aircraft:** Rotated arrow/chevron by heading. Coloured by category: civil (`--accent-cyan`), military (`--accent-orange`), emergency (`--accent-red`). Sized by altitude band (high/mid/low). +- **Vessels:** Ship-silhouette SVG rotated by COG. Coloured by vessel type. +- **Satellites:** Simple diamond marker with pass arc overlay. +- **Selection popup:** Dark glass panel (backdrop-filter blur, `--bg-elevated` background, `--border-color` border), monospace data rows, no default Leaflet chrome. + +### Tactical Overlays (shared map utility `static/js/map-utils.js`) + +- **Range rings:** Dashed SVG circles at configurable NM/km intervals, label at ring edge. +- **Lat/lon grid:** Subtle graticule overlay, toggleable, 5° or 10° spacing. +- **Observer reticle:** Crosshair SVG replacing the current circle marker. +- **Flight/vessel trails:** Gradient fade — full opacity at current position, transparent at tail. Trail length configurable. +- **HUD corner panels:** + - Top-left: contact count + mode name + - Top-right: UTC clock + SDR status dot + - Bottom-right: scale bar + - Panels use `backdrop-filter: blur(8px)`, dark glass style. + +### Implementation Note + +Extract shared map initialisation into `static/js/map-utils.js`. Each dashboard calls `MapUtils.init(container, options)` and `MapUtils.addTacticalOverlays(map, options)`. Reduces duplication across the 6 map-using pages. + +--- + +## Sub-Project 3: Mode Polish Sprint + +**Goal:** Apply the new design system to the 12 flagged modes. Consistent structure, no mode feeling like a rough prototype. + +**Flagged modes:** Pager, 433MHz Sensors, Sub-GHz, Morse/OOK, GPS, APRS, Radiosonde, WeFax, System Health, Meshtastic, VDL2, ACARS. + +### Shared Treatment (all 12 modes) + +- **Scan indicator:** Animated dot + status text in list header. Pattern from WiFi/Bluetooth applied universally. +- **Sort + filter controls row:** Where a list exists, add sort controls (signal/name/time) in the list header. +- **Empty states:** All modes use `components/empty_state.html` consistently with mode-specific copy. +- **Control panels:** Unified layout — label left, input right, consistent `--space-3` gaps. +- **Data rows:** 2-line row treatment — primary info on line 1, secondary metadata on line 2 (signal level, timestamp, etc.). + +### Mode-Specific Highlights + +**Pager, ACARS, VDL2:** +- Message rows: signal strength bar on the left edge, collapsible raw data section, relative timestamp with absolute on hover. +- Message type badge (coloured by category/priority). + +**433MHz Sensors:** +- Sensor cards grouped by device type with a category header. +- Last-seen freshness indicator (green → amber → red based on age). +- Per-sensor RSSI sparkline (reuse Chart.js pattern from WiFi signal history). + +**Sub-GHz, Morse/OOK, WeFax:** +- Signal visualisation panels get scanline texture + dark glass aesthetic. +- WeFax image gallery upgraded: lightbox overlay, larger thumbnails, metadata strip below each image. + +**Meshtastic:** +- Chat-style message layout (sent right, received left, timestamp inline). +- Node list with signal quality bars (RSSI + SNR visualised as bar segments). + +**System Health:** +- Metric cards in Grafana style: large value, small label, sparkline, status colour border. +- Process list: coloured status pill (running/stopped/error), uptime, PID. + +**GPS, APRS, Radiosonde:** +- Map-centric layout using the Sub-Project 2 shared map utility (requires Sub-Project 2 complete first). +- Sub-Project 2 handles the map surface itself (tiles, markers, overlays). Sub-Project 3 handles the surrounding UI: control panel layout, telemetry sidebar panel, empty states, and scan indicator. +- These two modes should be done last in the Sub-Project 3 sprint, after Sub-Project 2 is merged. + +--- + +## Sub-Project 4: New Features + +### 4a — Spectrum Overview Dashboard (`/spectrum`) + +A new full-screen dashboard showing a wideband frequency sweep (rtl_power) as a waterfall/bar chart. Colour-coded markers overlay the sweep at frequencies used by active modes. Clicking a marker navigates to that mode. + +- Backend: new `/spectrum` route + SSE stream from rtl_power. +- Frontend: Chart.js frequency bar chart, mode markers as overlaid labels. +- Entry point: new nav item in Signals group, plus a link from the System Health mode. + +### 4b — Alerts & Trigger Engine + +Rule-based alert system evaluated server-side against SSE event streams. + +- **Rule schema:** `{ event_type, condition_field, condition_op, condition_value, action_type, action_payload }` +- **Storage:** New `alerts` SQLite table in `instance/`. +- **UI:** Rule builder modal accessible from a new "Alerts" nav item. Rule list with enable/disable toggle, test button. +- **Initial event types:** + - Aircraft: callsign match, squawk code, altitude threshold, military flag + - Bluetooth: new tracker detected, specific MAC seen + - Pager: message keyword match + - AIS: vessel type, MMSI match +- **Actions:** Desktop notification, audio alert (reuse existing alert sounds), log to file. + +### 4c — Signal Recording & Playback + +Per-mode session recording of decoded output (not I/Q — storage/complexity deferred). + +- **Record:** "Record" toggle button in each mode's control panel. Saves decoded events as timestamped JSON to `data/recordings//.json`. +- **Playback:** Recording browser in a modal. Select a recording, choose playback speed (1×, 2×, 5×). Playback re-streams events through the existing SSE infrastructure via a `/playback/stream` endpoint. +- **I/Q recording:** Out of scope for this phase. + +### 4d — Signal Identification + +"What is this?" capability in the Listening Post (waterfall) mode. + +- User selects a frequency range on the waterfall. +- Backend calls `inspectrum` (if installed) or a lightweight Python classifier against the captured I/Q segment. +- Returns: estimated modulation type, bandwidth, symbol rate, and a match against a curated known-signals database (JSON file). +- UI: result panel slides up from the bottom of the waterfall with signal details and suggested decoder. +- Graceful degradation: button is hidden if `inspectrum` is not installed. + +### 4e — Mobile PWA + +Installable, responsive INTERCEPT for field use. + +- `static/manifest.json`: name, icons, display standalone, theme colour. +- Service worker (`static/sw.js`): cache shell assets for offline load. SSE streams remain live-only. +- **Responsive breakpoints** (`static/css/responsive.css`): sidebar collapses to bottom tab bar on `max-width: 768px`. Priority mobile views: BT Locate, WiFi Locate, GPS (field-use modes). Map-heavy modes scale gracefully. Control panels stack vertically on narrow screens. +- Home screen icons: generate from existing `favicon.svg` at 192×192 and 512×512. + +--- + +## Implementation Order + +1. Sub-Project 1 — Design System + Sidebar (foundation, ~1 week) +2. Sub-Project 2 — Maps Overhaul (high impact, ~1 week) +3. Sub-Project 3 — Mode Polish Sprint (breadth, ~2 weeks) +4. Sub-Project 4 — New Features (sequentially: Spectrum → Alerts → Recording → Signal ID → PWA) + +## Out of Scope + +- I/Q signal recording (deferred to a later phase) +- Backend infrastructure changes beyond what new features require +- New SDR hardware support +- Existing WiFi and Bluetooth modes (recently redesigned, not flagged) From 8c61af2863772db5857e9cb6b07abdeddbd4edaf Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 13 Apr 2026 16:50:22 +0100 Subject: [PATCH 34/63] docs: add Sub-Project 1 implementation plan (design system uplift) 6-task plan covering token deepening, nav active state glow, panel pulse animation, scanline texture, and localStorage nav group persistence. Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-13-design-system-uplift.md | 517 ++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-design-system-uplift.md diff --git a/docs/superpowers/plans/2026-04-13-design-system-uplift.md b/docs/superpowers/plans/2026-04-13-design-system-uplift.md new file mode 100644 index 0000000..dfcec90 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-design-system-uplift.md @@ -0,0 +1,517 @@ +# Design System Uplift + Sidebar Polish — 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:** Establish the "Mission Control" visual language as the new baseline — deeper blacks, cyan-glow active states, panel polish, scanline texture, and nav group state persistence — so every subsequent sub-project inherits a consistent, polished foundation. + +**Architecture:** Four targeted CSS file edits (variables, layout, components, index) plus one JS enhancement for localStorage-backed nav state. No structural changes; all changes are additive or small replacements within existing rules. + +**Tech Stack:** CSS custom properties, Jinja2 templates, vanilla JS, localStorage API + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `static/css/core/variables.css` | Modify | Deepen bg tokens; add `--accent-cyan-glow` and `--scanline` | +| `static/css/core/layout.css` | Modify | Nav active state → left-border glow; hover → cyan-glow bg; group header accent line | +| `static/css/core/components.css` | Modify | Panel indicator pulse animation; card vignette; scanline texture on visuals containers | +| `static/css/index.css` | Modify | Scanline texture on existing `.visuals-container` if not covered by components.css | +| `templates/partials/nav.html` | Modify | Add `data-group` attributes to dropdown containers (already present); verify `toggleNavDropdown` call exists | +| `templates/index.html` | Modify | Add `initNavGroupState()` call in page init; add the JS function | + +--- + +## Task 1: Deepen Background Tokens + +**Files:** +- Modify: `static/css/core/variables.css` + +Note on current values (from file, not the UI guide which is out of date): +- `--bg-primary` is currently `#0b1118` +- `--bg-secondary` is currently `#101823` +- `--bg-tertiary` is currently `#151f2b` +- `--bg-card` is currently `#121a25` + +- [ ] **Step 1: Update background tokens in dark theme** + +In `static/css/core/variables.css`, replace the four background values in the `:root` block: + +```css +/* Backgrounds - layered depth system */ +--bg-primary: #07090e; +--bg-secondary: #0b1018; +--bg-tertiary: #101520; +--bg-card: #0d1219; +--bg-elevated: #161d28; +``` + +- [ ] **Step 2: Add `--accent-cyan-glow` and `--scanline` tokens** + +In `static/css/core/variables.css`, after the `--accent-amber-dim` line, add: + +```css +--accent-cyan-glow: rgba(74, 163, 255, 0.12); +``` + +After the `--noise-image` line, add a new comment block and token: + +```css +/* Scanline overlay texture */ +--scanline: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.04) 2px, + rgba(0, 0, 0, 0.04) 4px +); +``` + +- [ ] **Step 3: Update light theme overrides to match** + +In `static/css/core/variables.css`, in the `[data-theme="light"]` block, the bg values don't need changing (they're already light colours). But add the missing `--accent-cyan-glow` override so it doesn't bleed through in light mode: + +```css +--accent-cyan-glow: rgba(31, 95, 168, 0.08); +--scanline: none; +``` + +- [ ] **Step 4: Verify no visual regressions** + +Start the dev server: `sudo -E venv/bin/python intercept.py` + +Open `http://localhost:5000` and check: +- Background is noticeably deeper/richer without being pure black +- Text remains readable +- No elements that used bg colours are now invisible (white text on near-white, etc.) + +- [ ] **Step 5: Commit** + +```bash +git add static/css/core/variables.css +git commit -m "style: deepen background tokens and add scanline/glow variables" +``` + +--- + +## Task 2: Nav Active State → Left-Border Glow + +**Files:** +- Modify: `static/css/core/layout.css` + +The current active state uses `box-shadow: inset 0 -2px 0 var(--accent-cyan)` (a bottom underline). We're replacing this with a left-border glow — more ops-center, less browser-tab. + +- [ ] **Step 1: Replace `.mode-nav-btn.active` style** + +In `static/css/core/layout.css`, find and replace the `.mode-nav-btn.active` block (currently at line ~749): + +```css +.mode-nav-btn.active { + background: var(--accent-cyan-glow); + color: var(--text-primary); + border-color: transparent; + border-left: 2px solid var(--accent-cyan); + box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2); + padding-left: 12px; /* compensate for 2px border */ +} +``` + +- [ ] **Step 2: Replace `.mode-nav-dropdown.has-active .mode-nav-dropdown-btn` style** + +Find and replace the `.mode-nav-dropdown.has-active .mode-nav-dropdown-btn` block (currently at line ~856): + +```css +.mode-nav-dropdown.has-active .mode-nav-dropdown-btn { + background: var(--accent-cyan-glow); + color: var(--text-primary); + border-color: transparent; + border-left: 2px solid var(--accent-cyan); + box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2); +} +``` + +- [ ] **Step 3: Replace `.mode-nav-dropdown-menu .mode-nav-btn.active` style** + +Find and replace the block at line ~903: + +```css +.mode-nav-dropdown-menu .mode-nav-btn.active { + background: var(--accent-cyan-glow); + color: var(--text-primary); + border-left: 2px solid var(--accent-cyan); + box-shadow: -2px 0 6px rgba(74, 163, 255, 0.15); + padding-left: 10px; +} +``` + +- [ ] **Step 4: Enhance hover state with cyan-glow background** + +Find `.mode-nav-btn:hover` (line ~743) and replace: + +```css +.mode-nav-btn:hover { + background: var(--accent-cyan-glow); + color: var(--text-primary); + border-color: var(--border-color); +} +``` + +Find `.mode-nav-dropdown-btn:hover` (line ~840) and replace: + +```css +.mode-nav-dropdown-btn:hover { + background: var(--accent-cyan-glow); + color: var(--text-primary); + border-color: var(--border-color); +} +``` + +- [ ] **Step 5: Update light theme active state overrides** + +Find the `[data-theme="light"] .mode-nav-btn.active` block (line ~1105) and replace: + +```css +[data-theme="light"] .mode-nav-btn.active { + background: rgba(31, 95, 168, 0.08); + border-left: 2px solid var(--accent-cyan); + box-shadow: -2px 0 6px rgba(31, 95, 168, 0.15); + padding-left: 12px; +} + +[data-theme="light"] .mode-nav-dropdown-btn:hover, +[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn, +[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn { + background: rgba(31, 95, 168, 0.06); + border-left: 2px solid var(--accent-cyan); + box-shadow: -2px 0 6px rgba(31, 95, 168, 0.12); +} + +[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active { + background: rgba(31, 95, 168, 0.08); + border-left: 2px solid var(--accent-cyan); + padding-left: 10px; +} +``` + +- [ ] **Step 6: Verify in browser** + +Open `http://localhost:5000`, switch between a few modes. Verify: +- Active mode button has visible left-border cyan glow +- Hover on inactive buttons shows subtle cyan-glow background +- Light theme still works (toggle via moon/sun icon) + +- [ ] **Step 7: Commit** + +```bash +git add static/css/core/layout.css +git commit -m "style: nav active state → left-border cyan glow, hover → glow bg" +``` + +--- + +## Task 3: Panel Indicator Pulse Animation + +**Files:** +- Modify: `static/css/core/components.css` + +The `.panel-indicator.active` currently has a static green dot with glow. We're adding a CSS pulse animation so active panels visually breathe. + +- [ ] **Step 1: Write a test for the panel indicator class** + +```bash +# Check that panel-indicator.active elements exist in the rendered HTML +# (manual spot-check — open any mode and inspect the DOM) +# Confirm .panel-indicator.active is present on the panel header dot +``` + +- [ ] **Step 2: Add pulse keyframes and apply to active indicator** + +In `static/css/core/components.css`, find `.panel-indicator.active` (line ~236) and replace the block: + +```css +@keyframes panel-pulse { + 0%, 100% { + box-shadow: 0 0 4px var(--status-online), 0 0 8px rgba(56, 193, 128, 0.4); + opacity: 1; + } + 50% { + box-shadow: 0 0 8px var(--status-online), 0 0 16px rgba(56, 193, 128, 0.6); + opacity: 0.85; + } +} + +.panel-indicator.active { + background: var(--status-online); + box-shadow: 0 0 8px var(--status-online); + animation: panel-pulse 2s ease-in-out infinite; +} +``` + +- [ ] **Step 3: Respect reduced-motion preference** + +Add below the above block: + +```css +@media (prefers-reduced-motion: reduce) { + .panel-indicator.active { + animation: none; + } +} +``` + +- [ ] **Step 4: Verify in browser** + +Open `http://localhost:5000`, start any mode (e.g. Pager). Verify the panel indicator dot pulses gently when active, is static when inactive. + +- [ ] **Step 5: Commit** + +```bash +git add static/css/core/components.css +git commit -m "style: add pulse animation to active panel indicators" +``` + +--- + +## Task 4: Card Vignette + Scanline Texture + +**Files:** +- Modify: `static/css/core/components.css` +- Modify: `static/css/index.css` (for `.visuals-container` if not in components.css) + +- [ ] **Step 1: Add inner vignette to `.panel` cards** + +In `static/css/core/components.css`, find the `.panel` rule. After the existing properties, add `box-shadow`: + +```css +.panel { + /* ... existing properties ... */ + box-shadow: var(--shadow-sm), inset 0 0 40px rgba(0, 0, 0, 0.25); +} +``` + +If a `.panel` rule doesn't exist at the top level in components.css, search for it: +```bash +grep -n "^\.panel {" static/css/core/components.css static/css/index.css +``` +Add the `box-shadow` to whichever file defines it. + +- [ ] **Step 2: Add scanline texture to visuals containers** + +Visuals containers are mode-specific and likely defined in `static/css/index.css`. Search for the class: + +```bash +grep -n "visuals-container" static/css/index.css static/css/core/components.css +``` + +In whichever file defines `.visuals-container`, add an `::after` pseudo-element: + +```css +.visuals-container { + position: relative; /* ensure this is set */ +} + +.visuals-container::after { + content: ''; + position: absolute; + inset: 0; + background: var(--scanline); + pointer-events: none; + z-index: 1; + border-radius: inherit; +} +``` + +- [ ] **Step 3: Verify scanline doesn't block interactions** + +Open a mode with a visuals container (e.g. Bluetooth radar, TSCM). Verify: +- Subtle horizontal scanline texture is visible +- Clicking/interacting with the visual still works (pointer-events: none is set) +- Light theme: scanline is `none` (set in Task 1 Step 3) + +- [ ] **Step 4: Commit** + +```bash +git add static/css/core/components.css static/css/index.css +git commit -m "style: add card vignette and scanline texture to visuals containers" +``` + +--- + +## Task 5: Nav Group State Persistence + +**Files:** +- Modify: `templates/index.html` + +The nav HTML already has `data-group` attributes on `.mode-nav-dropdown` containers and calls `toggleNavDropdown()` on button clicks. We need to persist which groups are open/closed so the state survives page reloads. + +- [ ] **Step 1: Find where `toggleNavDropdown` is defined** + +```bash +grep -n "toggleNavDropdown" templates/index.html +``` + +Note the line number where the function is defined (it will be inside a ` {% include 'partials/nav-utility-modals.html' %} + {% include 'partials/nav-utility-modals.html' %} + @@ -766,6 +767,7 @@ let passes = []; let selectedPass = null; let groundMap = null; + let satMapOverlays = null; let satMarker = null; let trackLine = null; let observerMarker = null; @@ -1906,103 +1908,35 @@ now.toISOString().substring(11, 19) + ' UTC'; } - function createFallbackGridLayer() { - const layer = L.gridLayer({ - tileSize: 256, - updateWhenIdle: true, - attribution: 'Local fallback grid' - }); - layer.createTile = function(coords) { - const tile = document.createElement('canvas'); - tile.width = 256; - tile.height = 256; - const ctx = tile.getContext('2d'); - - ctx.fillStyle = '#08121c'; - ctx.fillRect(0, 0, 256, 256); - - ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(256, 0); - ctx.moveTo(0, 0); - ctx.lineTo(0, 256); - ctx.stroke(); - - ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; - ctx.beginPath(); - ctx.moveTo(128, 0); - ctx.lineTo(128, 256); - ctx.moveTo(0, 128); - ctx.lineTo(256, 128); - ctx.stroke(); - - ctx.fillStyle = 'rgba(160, 220, 255, 0.28)'; - ctx.font = '11px "JetBrains Mono", monospace'; - ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22); - - return tile; - }; - return layer; - } - - async function upgradeGroundTilesFromSettings(fallbackTiles) { - if (typeof Settings === 'undefined' || !groundMap) return; - - try { - await Settings.init(); - if (!groundMap) return; - - const configuredLayer = Settings.createTileLayer(); - let tileLoaded = false; - - configuredLayer.once('load', () => { - tileLoaded = true; - if (groundMap && fallbackTiles && groundMap.hasLayer(fallbackTiles)) { - groundMap.removeLayer(fallbackTiles); - } - groundMap.invalidateSize(false); - }); - - configuredLayer.on('tileerror', () => { - if (!tileLoaded) { - console.warn('Satellite tile layer failed to load, keeping fallback grid'); - } - }); - - configuredLayer.addTo(groundMap); - Settings.registerMap(groundMap); - } catch (e) { - console.warn('Satellite: Settings/tile upgrade failed, using fallback grid:', e); - } - } async function initGroundMap() { - const container = document.getElementById('groundMap'); - if (!container || container._leaflet_id) return; + const mapContainer = document.getElementById('groundMap'); + if (!mapContainer || groundMap) return; - groundMap = L.map('groundMap', { + groundMap = MapUtils.init('groundMap', { center: [20, 0], - zoom: 2, + zoom: 1, minZoom: 1, maxZoom: 10, - worldCopyJump: true + attributionControl: false, }); - + if (!groundMap) return; window.groundMap = groundMap; - // Use a zero-network fallback so dashboard navigation stays fast even - // when internet map providers are slow or unreachable. - const fallbackTiles = createFallbackGridLayer().addTo(groundMap); + satMapOverlays = MapUtils.addTacticalOverlays(groundMap, { + hudPanels: { + modeName: 'SAT TRACK', + getContactCount: () => 0, + }, + scaleBar: true, + }); - upgradeGroundTilesFromSettings(fallbackTiles); + // Add observer marker via reticle + const lat = parseFloat(document.getElementById('obsLat')?.value) || 51.5; + const lon = parseFloat(document.getElementById('obsLon')?.value) || -0.1; + observerMarker = MapUtils._buildReticle([lat, lon]); + observerMarker.addTo(groundMap); - const lat = parseFloat(document.getElementById('obsLat')?.value); - const lon = parseFloat(document.getElementById('obsLon')?.value); - if (!Number.isNaN(lat) && !Number.isNaN(lon)) { - groundMap.setView([lat, lon], 3); - } requestAnimationFrame(() => groundMap?.invalidateSize(false)); setTimeout(() => groundMap?.invalidateSize(false), 250); updateMapModeButtons(); @@ -3163,6 +3097,7 @@ {% include 'partials/nav-utility-modals.html' %} + @@ -9986,6 +9987,7 @@ // APRS Functions // ============================================ let aprsMap = null; + let aprsMapOverlays = null; let aprsMarkers = {}; let aprsEventSource = null; let isAprsRunning = false; @@ -10137,47 +10139,6 @@ }); } - function createAprsFallbackGridLayer() { - const layer = L.gridLayer({ - tileSize: 256, - updateWhenIdle: true, - attribution: 'Local fallback grid' - }); - layer.createTile = function(coords) { - const tile = document.createElement('canvas'); - tile.width = 256; - tile.height = 256; - const ctx = tile.getContext('2d'); - - ctx.fillStyle = '#08121c'; - ctx.fillRect(0, 0, 256, 256); - - ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(256, 0); - ctx.moveTo(0, 0); - ctx.lineTo(0, 256); - ctx.stroke(); - - ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; - ctx.beginPath(); - ctx.moveTo(128, 0); - ctx.lineTo(128, 256); - ctx.moveTo(0, 128); - ctx.lineTo(256, 128); - ctx.stroke(); - - ctx.fillStyle = 'rgba(160, 220, 255, 0.28)'; - ctx.font = '11px "JetBrains Mono", monospace'; - ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22); - - return tile; - }; - return layer; - } - async function initAprsMap() { if (aprsMap) return; @@ -10206,26 +10167,22 @@ const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795); const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4; - aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom); + aprsMap = MapUtils.init('aprsMap', { + center: [initialLat, initialLon], + zoom: initialZoom, + minZoom: 2, + maxZoom: 18, + }); + if (!aprsMap) return; window.aprsMap = aprsMap; - // Zero-network fallback so mode switches never block on external tiles. - const fallbackTiles = createAprsFallbackGridLayer().addTo(aprsMap); - - // Upgrade tiles in background via Settings (with timeout fallback) - if (typeof Settings !== 'undefined') { - try { - await Promise.race([ - Settings.init(), - new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000)) - ]); - aprsMap.removeLayer(fallbackTiles); - Settings.createTileLayer().addTo(aprsMap); - Settings.registerMap(aprsMap); - } catch (e) { - console.warn('APRS: Settings init failed/timed out, using fallback tiles:', e); - } - } + aprsMapOverlays = MapUtils.addTacticalOverlays(aprsMap, { + hudPanels: { + modeName: 'APRS', + getContactCount: () => Object.keys(aprsStations).length, + }, + scaleBar: true, + }); // Add user marker if GPS position is already available if (gpsConnected && hasGpsLocation) { @@ -11327,6 +11284,7 @@ // Ground Track Map let groundTrackMap = null; + let gpsMapOverlays = null; let groundTrackLine = null; let satMarker = null; let observerMarker = null; @@ -11336,47 +11294,28 @@ const mapContainer = document.getElementById('groundTrackMap'); if (!mapContainer || groundTrackMap) return; - groundTrackMap = L.map('groundTrackMap', { + groundTrackMap = MapUtils.init('groundTrackMap', { center: [20, 0], zoom: 1, zoomControl: true, - attributionControl: false + attributionControl: false, }); + if (!groundTrackMap) return; window.groundTrackMap = groundTrackMap; - // Add fallback tiles immediately so the map is visible instantly - const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { - attribution: '© OSM © CARTO', - maxZoom: 19, - subdomains: 'abcd', - className: 'tile-layer-cyan' - }).addTo(groundTrackMap); + gpsMapOverlays = MapUtils.addTacticalOverlays(groundTrackMap, { + hudPanels: { + modeName: 'GPS', + getContactCount: () => 0, + }, + scaleBar: true, + }); - // Upgrade tiles in background via Settings (with timeout fallback) - if (typeof Settings !== 'undefined') { - try { - await Promise.race([ - Settings.init(), - new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000)) - ]); - groundTrackMap.removeLayer(fallbackTiles); - Settings.createTileLayer().addTo(groundTrackMap); - Settings.registerMap(groundTrackMap); - } catch (e) { - console.warn('Ground track: Settings init failed/timed out, using fallback tiles:', e); - } - } - - // Add observer marker - const lat = parseFloat(document.getElementById('obsLat').value) || 51.5; - const lon = parseFloat(document.getElementById('obsLon').value) || -0.1; - observerMarker = L.circleMarker([lat, lon], { - radius: 8, - fillColor: '#ff6600', - color: '#fff', - weight: 2, - fillOpacity: 1 - }).addTo(groundTrackMap).bindPopup('Observer Location'); + // Observer crosshair via MapUtils + const obsLat = parseFloat(document.getElementById('obsLat')?.value) || 51.5; + const obsLon = parseFloat(document.getElementById('obsLon')?.value) || -0.1; + observerMarker = MapUtils._buildReticle([obsLat, obsLon]); + observerMarker.addTo(groundTrackMap); } function updateGroundTrack(pass) { @@ -16474,6 +16413,7 @@ + diff --git a/tests/test_map_utils.py b/tests/test_map_utils.py index f9899df..8a0660f 100644 --- a/tests/test_map_utils.py +++ b/tests/test_map_utils.py @@ -51,3 +51,14 @@ def test_satellite_dashboard_includes_map_utils(client): html = resp.data.decode() assert "map-utils.js" in html assert "MapUtils.init" in html + + +def test_index_includes_map_utils(client): + """Main SPA index.html loads map-utils.js and uses it for APRS and GPS maps.""" + with client.session_transaction() as sess: + sess["logged_in"] = True + resp = client.get("/") + assert resp.status_code == 200 + html = resp.data.decode() + assert "map-utils.js" in html + assert "MapUtils.init" in html From ac2f7ea032230cfcc7bc439671cec63d1727a34f Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 13 Apr 2026 23:15:53 +0100 Subject: [PATCH 54/63] fix(aprs): correct aprsMarkers variable name and reset aprsMapOverlays on teardown --- templates/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/index.html b/templates/index.html index 0b72ba7..20cda1d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10179,7 +10179,7 @@ aprsMapOverlays = MapUtils.addTacticalOverlays(aprsMap, { hudPanels: { modeName: 'APRS', - getContactCount: () => Object.keys(aprsStations).length, + getContactCount: () => Object.keys(aprsMarkers).length, }, scaleBar: true, }); @@ -10225,6 +10225,7 @@ } catch (_) {} aprsMap = null; window.aprsMap = null; + aprsMapOverlays = null; } aprsMarkers = {}; aprsUserMarker = null; From 3693b02cb968e275b6045d391b0e0d24b26ffd66 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 13 Apr 2026 23:27:36 +0100 Subject: [PATCH 55/63] refactor(radiosonde): use MapUtils.init + Settings tile layer on sonde map Replace hardcoded L.map() + CartoCD dark tile layer with MapUtils.init() and add tactical overlays. Adds test verifying the cartocdn URL is gone. Co-Authored-By: Claude Sonnet 4.6 --- templates/partials/modes/radiosonde.html | 24 ++++++++++++++++-------- tests/test_map_utils.py | 13 +++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/templates/partials/modes/radiosonde.html b/templates/partials/modes/radiosonde.html index 7726989..c7ff659 100644 --- a/templates/partials/modes/radiosonde.html +++ b/templates/partials/modes/radiosonde.html @@ -278,6 +278,7 @@ // Map management let radiosondeMap = null; + let radiosondeMapOverlays = null; let radiosondeMarkers = new Map(); let radiosondeTracks = new Map(); let radiosondeTrackPoints = new Map(); @@ -295,16 +296,23 @@ } const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0; - radiosondeMap = L.map('radiosondeMapContainer', { - center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95], - zoom: hasLocation ? 7 : 4, - zoomControl: true, - }); + const observerLocation = hasLocation + ? { lat: radiosondeStationLocation.lat, lon: radiosondeStationLocation.lon } + : { lat: 40, lon: -95 }; - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap © CARTO', + radiosondeMap = MapUtils.init('radiosondeMapContainer', { + center: [observerLocation.lat, observerLocation.lon], + zoom: hasLocation ? 7 : 4, + minZoom: 2, maxZoom: 18, - }).addTo(radiosondeMap); + }); + if (radiosondeMap) { + window.radiosondeMap = radiosondeMap; + radiosondeMapOverlays = MapUtils.addTacticalOverlays(radiosondeMap, { + hudPanels: { modeName: 'RADIOSONDE', getContactCount: () => 0 }, + scaleBar: true, + }); + } // Add station marker if we have a location if (hasLocation) { diff --git a/tests/test_map_utils.py b/tests/test_map_utils.py index 8a0660f..8fa0f15 100644 --- a/tests/test_map_utils.py +++ b/tests/test_map_utils.py @@ -62,3 +62,16 @@ def test_index_includes_map_utils(client): html = resp.data.decode() assert "map-utils.js" in html assert "MapUtils.init" in html + + +def test_radiosonde_mode_uses_map_utils(client): + """Main SPA index (which includes radiosonde partial) uses MapUtils for radiosonde map.""" + with client.session_transaction() as sess: + sess["logged_in"] = True + resp = client.get("/") + assert resp.status_code == 200 + html = resp.data.decode() + # Radiosonde map init in partial should call MapUtils.init, not bare L.tileLayer + assert "radiosondeMap" in html + # The bare cartocdn URL that was previously hardcoded should be gone + assert "basemaps.cartocdn.com/dark_all" not in html or html.count("basemaps.cartocdn.com/dark_all") == 0 From 8ae19beef6198de55e502684561804a125b107f6 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 13 Apr 2026 23:33:40 +0100 Subject: [PATCH 56/63] fix(maps): remove defer from map-utils.js in index.html for consistency --- templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/index.html b/templates/index.html index 20cda1d..3515c25 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16414,7 +16414,7 @@ - + From 6dce9111723a5d16b579aab292eca1eadf49fa98 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 13 Apr 2026 23:46:03 +0100 Subject: [PATCH 57/63] =?UTF-8?q?fix(maps):=20remove=20duplicate=20overlay?= =?UTF-8?q?s=20=E2=80=94=20drop=20HUD=20panels=20from=20APRS/GPS=20(have?= =?UTF-8?q?=20own=20header),=20drop=20MapUtils=20range=20rings=20from=20AD?= =?UTF-8?q?S-B=20(drawRangeRings()=20owns=20them)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/adsb_dashboard.html | 10 ++-------- templates/index.html | 8 -------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index f9ddd99..78bab31 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -2234,15 +2234,9 @@ sudo make install setTimeout(() => { if (radarMap) radarMap.invalidateSize(); }, 200); setTimeout(() => { if (radarMap) radarMap.invalidateSize(); }, 500); - // Tactical overlays: range rings + observer reticle + HUD panels - const intervals = [maxRange * 0.25, maxRange * 0.5, maxRange * 0.75, maxRange] - .map(n => Math.round(n)); + // Tactical overlays: observer reticle + HUD panels + scale bar + // Range rings are managed by drawRangeRings() which handles toggle, range changes, and observer moves mapOverlays = MapUtils.addTacticalOverlays(radarMap, { - rangeRings: { - center: [observerLocation.lat, observerLocation.lon], - intervals, - unit: 'nm', - }, observerReticle: { latlng: [observerLocation.lat, observerLocation.lon] }, hudPanels: { modeName: 'ADS-B', diff --git a/templates/index.html b/templates/index.html index 3515c25..a7640bb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10177,10 +10177,6 @@ window.aprsMap = aprsMap; aprsMapOverlays = MapUtils.addTacticalOverlays(aprsMap, { - hudPanels: { - modeName: 'APRS', - getContactCount: () => Object.keys(aprsMarkers).length, - }, scaleBar: true, }); @@ -11305,10 +11301,6 @@ window.groundTrackMap = groundTrackMap; gpsMapOverlays = MapUtils.addTacticalOverlays(groundTrackMap, { - hudPanels: { - modeName: 'GPS', - getContactCount: () => 0, - }, scaleBar: true, }); From f12f4145efe5571d0f3ee1aa7fd46f4b395359b4 Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 15 Apr 2026 12:45:58 +0100 Subject: [PATCH 58/63] docs(alerts): document webhook/notification system in USAGE.md fix(db): use gevent-safe local storage for DB connections under gunicorn+gevent Co-Authored-By: Claude Sonnet 4.6 --- docs/USAGE.md | 144 ++++ utils/database.py | 1625 +++++++++++++++++++++++---------------------- 2 files changed, 985 insertions(+), 784 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 3d677f3..963200c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -539,6 +539,150 @@ Enable "Show All Agents" to aggregate data from all registered agents simultaneo For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md). +## Webhooks & Notifications + +INTERCEPT has a built-in alert engine that fires webhooks when decoded events match configurable rules. This lets you forward pager messages (or events from any other mode) to Discord, Slack, n8n, Home Assistant, or any HTTP endpoint. + +### How it works + +1. You configure **alert rules** via the Alerts UI — each rule defines which mode and event type to watch, optional match criteria, and a severity level. +2. When an incoming event matches a rule, INTERCEPT stores it in the alert log and POSTs a JSON payload to your configured webhook URL. +3. All modes are supported: pager, sensor, ADS-B, AIS, ACARS, WiFi, Bluetooth, and more. + +### Enable the webhook + +Set these environment variables in your `.env` file or `docker-compose.yml`: + +| Variable | Default | Description | +|----------|---------|-------------| +| `ALERT_WEBHOOK_URL` | _(empty)_ | URL to POST alert payloads to | +| `ALERT_WEBHOOK_SECRET` | _(empty)_ | Optional token sent as `X-Alert-Token` header | +| `ALERT_WEBHOOK_TIMEOUT` | `5` | HTTP timeout in seconds | + +**Local install (`.env`):** +```env +ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts +ALERT_WEBHOOK_SECRET=mysecrettoken +``` + +**Docker (`.env` or `docker-compose.yml` environment block):** +```env +ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts +ALERT_WEBHOOK_SECRET=mysecrettoken +``` + +### Create an alert rule + +1. Open the **Alerts** panel in INTERCEPT +2. Click **New Rule** +3. Configure: + - **Mode**: `pager` (or any other mode, or leave blank to match all) + - **Event type**: `message` for pager decodes (or blank to match all event types) + - **Match criteria**: leave empty to forward everything, or add filters (e.g. capcode equals `1234567`, or message contains `FIRE`) + - **Severity**: `low`, `medium`, or `high` +4. Save and enable the rule + +### Webhook payload format + +INTERCEPT sends a POST request with `Content-Type: application/json`: + +```json +{ + "id": 42, + "rule_id": 1, + "mode": "pager", + "event_type": "message", + "severity": "medium", + "title": "My Pager Rule", + "message": "message | 1234567", + "created_at": "2026-04-13T10:00:00+00:00", + "payload": { + "mode": "pager", + "event_type": "message", + "event": { + "capcode": "1234567", + "message": "UNIT 4 RESPOND TO 123 MAIN ST", + "type": "POCSAG1200" + }, + "rule": { "id": 1, "name": "My Pager Rule" } + } +} +``` + +### Sending to Discord + +Discord webhooks expect a specific JSON format (`content`, `embeds`), so you need a small relay between INTERCEPT and Discord. Two options: + +**Option A — No-code relay (recommended)** + +Use [n8n](https://n8n.io), [Make](https://make.com), or [Pipedream](https://pipedream.com) to receive INTERCEPT's webhook and forward it to Discord with a custom message template. Point `ALERT_WEBHOOK_URL` at your workflow's ingest URL. + +**Option B — Self-hosted Python relay** + +Save this as `discord_relay.py` and run it alongside INTERCEPT: + +```python +from flask import Flask, request +import urllib.request, json + +app = Flask(__name__) + +DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN" + +@app.post("/relay") +def relay(): + data = request.get_json(force=True) + mode = data.get("mode", "unknown").upper() + title = data.get("title", "Alert") + message = data.get("message", "") + event = data.get("payload", {}).get("event", {}) + + # Build a readable Discord message + lines = [f"**[{mode}]** {title}", message] + if event.get("capcode"): + lines.append(f"Capcode: `{event['capcode']}`") + if event.get("type"): + lines.append(f"Protocol: {event['type']}") + + payload = json.dumps({"content": "\n".join(lines)}).encode() + req = urllib.request.Request( + DISCORD_WEBHOOK_URL, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=5) + return "", 204 + +app.run(host="0.0.0.0", port=5051) +``` + +Then set: +```env +ALERT_WEBHOOK_URL=http://localhost:5051/relay +``` + +Run the relay: `python3 discord_relay.py` + +The relay formats pager decodes as Discord messages like: + +``` +[PAGER] My Pager Rule +message | 1234567 +Capcode: `1234567` +Protocol: POCSAG1200 +``` + +### Filtering specific capcodes + +To only forward decodes from a specific capcode, set the rule's **Match criteria**: + +| Field | Operator | Value | +|-------|----------|-------| +| `capcode` | equals | `1234567` | + +Multiple rules can coexist — e.g. one rule for all pager traffic to a general Discord channel, and a second rule for emergency capcodes with `high` severity to a separate channel (using a second relay instance on a different port). + ## Configuration INTERCEPT can be configured via environment variables: diff --git a/utils/database.py b/utils/database.py index 29aba76..b0f2b0e 100644 --- a/utils/database.py +++ b/utils/database.py @@ -14,14 +14,23 @@ from typing import Any from werkzeug.security import check_password_hash, generate_password_hash -logger = logging.getLogger('intercept.database') +logger = logging.getLogger("intercept.database") # Database file location -DB_DIR = Path(__file__).parent.parent / 'instance' -DB_PATH = DB_DIR / 'intercept.db' +DB_DIR = Path(__file__).parent.parent / "instance" +DB_PATH = DB_DIR / "intercept.db" -# Thread-local storage for connections -_local = threading.local() +# Per-greenlet (or per-thread) local storage for connections. +# Under gunicorn + gevent, all greenlets share a single OS thread, so +# threading.local() would give every concurrent request the same connection, +# causing sqlite3.ProgrammingError / database-locked crashes. Use gevent's +# local() when available so each greenlet gets its own connection. +try: + from gevent.local import local as _LocalClass +except ImportError: + _LocalClass = threading.local # type: ignore[assignment] + +_local = _LocalClass() def get_db_path() -> Path: @@ -32,15 +41,15 @@ def get_db_path() -> Path: def get_connection() -> sqlite3.Connection: """Get a thread-local database connection.""" - if not hasattr(_local, 'connection') or _local.connection is None: + if not hasattr(_local, "connection") or _local.connection is None: db_path = get_db_path() try: _local.connection = sqlite3.connect(str(db_path), check_same_thread=False) _local.connection.row_factory = sqlite3.Row # Enable foreign keys - _local.connection.execute('PRAGMA foreign_keys = ON') + _local.connection.execute("PRAGMA foreign_keys = ON") # Use WAL mode for better concurrent read/write performance - _local.connection.execute('PRAGMA journal_mode = WAL') + _local.connection.execute("PRAGMA journal_mode = WAL") except sqlite3.OperationalError as e: logger.error( f"Cannot open database at {db_path}: {e}. " @@ -82,10 +91,7 @@ def _check_db_writable(db_path: Path) -> None: # Check file writability if it already exists if db_path.exists() and not os.access(db_path, os.W_OK): owner = _file_owner(db_path) - msg = ( - f"Database {db_path} is not writable (owned by {owner}). " - f"Fix with: sudo chown -R $(whoami) {db_dir}" - ) + msg = f"Database {db_path} is not writable (owned by {owner}). Fix with: sudo chown -R $(whoami) {db_dir}" logger.error(msg) raise sqlite3.OperationalError(msg) @@ -94,6 +100,7 @@ def _file_owner(path: Path) -> str: """Return the owner username of a file, or UID if lookup fails.""" try: import pwd + return pwd.getpwuid(path.stat().st_uid).pw_name except (ImportError, KeyError): return str(path.stat().st_uid) @@ -108,17 +115,17 @@ def init_db() -> None: with get_db() as conn: # Settings table for key-value storage - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, value_type TEXT DEFAULT 'string', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Signal history table for graphs - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS signal_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, mode TEXT NOT NULL, @@ -127,16 +134,16 @@ def init_db() -> None: timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, metadata TEXT ) - ''') + """) # Create index for faster queries - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_signal_history_mode_device ON signal_history(mode, device_id, timestamp) - ''') + """) # Device correlation table - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS device_correlations ( id INTEGER PRIMARY KEY AUTOINCREMENT, wifi_mac TEXT, @@ -147,10 +154,10 @@ def init_db() -> None: metadata TEXT, UNIQUE(wifi_mac, bt_mac) ) - ''') + """) # Alert rules - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -162,10 +169,10 @@ def init_db() -> None: notify TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Alert events - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_id INTEGER, @@ -178,10 +185,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL ) - ''') + """) # Session recordings - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS recording_sessions ( id TEXT PRIMARY KEY, mode TEXT NOT NULL, @@ -193,10 +200,10 @@ def init_db() -> None: size_bytes INTEGER DEFAULT 0, metadata TEXT ) - ''') + """) # Alert rules - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -208,10 +215,10 @@ def init_db() -> None: notify TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Alert events - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_id INTEGER, @@ -224,10 +231,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL ) - ''') + """) # Session recordings - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS recording_sessions ( id TEXT PRIMARY KEY, mode TEXT NOT NULL, @@ -239,10 +246,10 @@ def init_db() -> None: size_bytes INTEGER DEFAULT 0, metadata TEXT ) - ''') + """) # Users table for authentication - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, @@ -250,11 +257,11 @@ def init_db() -> None: role TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) from config import ADMIN_PASSWORD, ADMIN_USERNAME - cursor = conn.execute('SELECT COUNT(*) FROM users') + cursor = conn.execute("SELECT COUNT(*) FROM users") if cursor.fetchone()[0] == 0: # First run — seed the admin user from config / env vars. admin_password = ADMIN_PASSWORD @@ -265,7 +272,7 @@ def init_db() -> None: logger.warning(f"Generated admin password: {admin_password}") logger.warning("Set INTERCEPT_ADMIN_PASSWORD env var to use a fixed password.") try: - pw_path = Path('instance/.initial_password') + pw_path = Path("instance/.initial_password") pw_path.parent.mkdir(parents=True, exist_ok=True) pw_path.write_text(f"{ADMIN_USERNAME}:{admin_password}\n") except OSError as e: @@ -274,29 +281,32 @@ def init_db() -> None: logger.info(f"Creating default admin user: {ADMIN_USERNAME}") hashed_pw = generate_password_hash(admin_password) - conn.execute(''' + conn.execute( + """ INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) - ''', (ADMIN_USERNAME, hashed_pw, 'admin')) + """, + (ADMIN_USERNAME, hashed_pw, "admin"), + ) elif ADMIN_PASSWORD: # Sync admin credentials from config on every startup so that # changes to config.py / env vars take effect without wiping the DB. row = conn.execute( - 'SELECT password_hash FROM users WHERE username = ? AND role = ?', - (ADMIN_USERNAME, 'admin'), + "SELECT password_hash FROM users WHERE username = ? AND role = ?", + (ADMIN_USERNAME, "admin"), ).fetchone() if row: - if not check_password_hash(row['password_hash'], ADMIN_PASSWORD): + if not check_password_hash(row["password_hash"], ADMIN_PASSWORD): conn.execute( - 'UPDATE users SET password_hash = ? WHERE username = ? AND role = ?', - (generate_password_hash(ADMIN_PASSWORD), ADMIN_USERNAME, 'admin'), + "UPDATE users SET password_hash = ? WHERE username = ? AND role = ?", + (generate_password_hash(ADMIN_PASSWORD), ADMIN_USERNAME, "admin"), ) logger.info(f"Admin password updated from config for user '{ADMIN_USERNAME}'.") else: # Admin user doesn't exist (maybe renamed) — create it. conn.execute( - 'INSERT OR IGNORE INTO users (username, password_hash, role) VALUES (?, ?, ?)', - (ADMIN_USERNAME, generate_password_hash(ADMIN_PASSWORD), 'admin'), + "INSERT OR IGNORE INTO users (username, password_hash, role) VALUES (?, ?, ?)", + (ADMIN_USERNAME, generate_password_hash(ADMIN_PASSWORD), "admin"), ) logger.info(f"Created admin user '{ADMIN_USERNAME}' from config.") # ===================================================================== @@ -304,7 +314,7 @@ def init_db() -> None: # ===================================================================== # TSCM Baselines - Environment snapshots for comparison - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_baselines ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -318,18 +328,18 @@ def init_db() -> None: gps_coords TEXT, is_active BOOLEAN DEFAULT 0 ) - ''') + """) # Ensure new columns exist for older databases try: - columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")} - if 'wifi_clients' not in columns: - conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT') + columns = {row["name"] for row in conn.execute("PRAGMA table_info(tscm_baselines)")} + if "wifi_clients" not in columns: + conn.execute("ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT") except Exception as e: logger.debug(f"Schema update skipped for tscm_baselines: {e}") # TSCM Sweeps - Individual sweep sessions - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_sweeps ( id INTEGER PRIMARY KEY AUTOINCREMENT, baseline_id INTEGER, @@ -345,10 +355,10 @@ def init_db() -> None: threats_found INTEGER DEFAULT 0, FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id) ) - ''') + """) # TSCM Threats - Detected threats/anomalies - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_threats ( id INTEGER PRIMARY KEY AUTOINCREMENT, sweep_id INTEGER, @@ -366,10 +376,10 @@ def init_db() -> None: gps_coords TEXT, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Scheduled Sweeps - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -384,10 +394,10 @@ def init_db() -> None: notify_email TEXT, FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id) ) - ''') + """) # TSCM Device Timelines - Periodic observations per device - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_device_timelines ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_identifier TEXT NOT NULL, @@ -401,10 +411,10 @@ def init_db() -> None: attributes TEXT, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Known-Good Registry - Whitelist of expected devices - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_known_devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, identifier TEXT NOT NULL UNIQUE, @@ -419,10 +429,10 @@ def init_db() -> None: score_modifier INTEGER DEFAULT -2, metadata TEXT ) - ''') + """) # TSCM Cases - Grouping sweeps, threats, and notes - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_cases ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -438,10 +448,10 @@ def init_db() -> None: notes TEXT, metadata TEXT ) - ''') + """) # TSCM Case Sweeps - Link sweeps to cases - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_case_sweeps ( case_id INTEGER, sweep_id INTEGER, @@ -450,10 +460,10 @@ def init_db() -> None: FOREIGN KEY (case_id) REFERENCES tscm_cases(id), FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Case Threats - Link threats to cases - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_case_threats ( case_id INTEGER, threat_id INTEGER, @@ -462,10 +472,10 @@ def init_db() -> None: FOREIGN KEY (case_id) REFERENCES tscm_cases(id), FOREIGN KEY (threat_id) REFERENCES tscm_threats(id) ) - ''') + """) # TSCM Case Notes - Notes attached to cases - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_case_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, case_id INTEGER, @@ -475,10 +485,10 @@ def init_db() -> None: created_by TEXT, FOREIGN KEY (case_id) REFERENCES tscm_cases(id) ) - ''') + """) # TSCM Meeting Windows - Track sensitive periods - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_meeting_windows ( id INTEGER PRIMARY KEY AUTOINCREMENT, sweep_id INTEGER, @@ -489,10 +499,10 @@ def init_db() -> None: notes TEXT, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Sweep Capabilities - Store sweep capability snapshot - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_sweep_capabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, sweep_id INTEGER UNIQUE, @@ -501,45 +511,45 @@ def init_db() -> None: recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM indexes for performance - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_threats_sweep ON tscm_threats(sweep_id) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_threats_severity ON tscm_threats(severity, detected_at) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_sweeps_baseline ON tscm_sweeps(baseline_id) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_timelines_device ON tscm_device_timelines(device_identifier, timestamp) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_known_devices_identifier ON tscm_known_devices(identifier) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_cases_status ON tscm_cases(status, created_at) - ''') + """) # ===================================================================== # DSC (Digital Selective Calling) Tables # ===================================================================== # DSC Alerts - Permanent storage for DISTRESS/URGENCY messages - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS dsc_alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -555,24 +565,24 @@ def init_db() -> None: acknowledged BOOLEAN DEFAULT 0, notes TEXT ) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_dsc_alerts_category ON dsc_alerts(category, received_at) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_dsc_alerts_mmsi ON dsc_alerts(source_mmsi, received_at) - ''') + """) # ===================================================================== # Remote Agent Tables (for distributed/controller mode) # ===================================================================== # Remote agents registry - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS agents ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, @@ -586,10 +596,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT 1 ) - ''') + """) # Push payloads received from remote agents - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS push_payloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id INTEGER NOT NULL, @@ -599,21 +609,21 @@ def init_db() -> None: received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (agent_id) REFERENCES agents(id) ) - ''') + """) # Indexes for agent tables - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_push_payloads_agent ON push_payloads(agent_id, received_at) - ''') + """) # Tracked satellites table for persistent satellite management - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tracked_satellites ( id INTEGER PRIMARY KEY AUTOINCREMENT, norad_id TEXT UNIQUE NOT NULL, @@ -624,51 +634,51 @@ def init_db() -> None: builtin BOOLEAN DEFAULT 0, added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Seed builtin satellites if not already present - conn.execute(''' + conn.execute(""" INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1) - ''') - conn.execute(''' - INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) - VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) - ''') - conn.execute(''' - INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) - VALUES ('57166', 'METEOR-M2-3', NULL, NULL, 1, 1) - ''') - conn.execute(''' - INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) - VALUES ('59051', 'METEOR-M2-4', NULL, NULL, 1, 1) - ''') + """) + conn.execute(""" + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) + """) + conn.execute(""" + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('57166', 'METEOR-M2-3', NULL, NULL, 1, 1) + """) + conn.execute(""" + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('59051', 'METEOR-M2-4', NULL, NULL, 1, 1) + """) # ===================================================================== # Ground Station Tables (automated observations, IQ recordings) # ===================================================================== # Observation profiles — per-satellite capture configuration - conn.execute(''' - CREATE TABLE IF NOT EXISTS observation_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - norad_id INTEGER UNIQUE NOT NULL, - name TEXT NOT NULL, - frequency_mhz REAL NOT NULL, - decoder_type TEXT NOT NULL DEFAULT 'fm', - tasks_json TEXT, - gain REAL DEFAULT 40.0, - bandwidth_hz INTEGER DEFAULT 200000, - min_elevation REAL DEFAULT 10.0, - enabled BOOLEAN DEFAULT 1, - record_iq BOOLEAN DEFAULT 0, + conn.execute(""" + CREATE TABLE IF NOT EXISTS observation_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + norad_id INTEGER UNIQUE NOT NULL, + name TEXT NOT NULL, + frequency_mhz REAL NOT NULL, + decoder_type TEXT NOT NULL DEFAULT 'fm', + tasks_json TEXT, + gain REAL DEFAULT 40.0, + bandwidth_hz INTEGER DEFAULT 200000, + min_elevation REAL DEFAULT 10.0, + enabled BOOLEAN DEFAULT 1, + record_iq BOOLEAN DEFAULT 0, iq_sample_rate INTEGER DEFAULT 2400000, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Observation history — one row per captured pass - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS ground_station_observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER, @@ -682,10 +692,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (profile_id) REFERENCES observation_profiles(id) ON DELETE SET NULL ) - ''') + """) # Per-observation events (packets decoded, Doppler updates, etc.) - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS ground_station_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, observation_id INTEGER, @@ -694,90 +704,88 @@ def init_db() -> None: timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE ) - ''') + """) # SigMF recordings — one row per IQ recording file pair - conn.execute(''' - CREATE TABLE IF NOT EXISTS sigmf_recordings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - observation_id INTEGER, - sigmf_data_path TEXT NOT NULL, + conn.execute(""" + CREATE TABLE IF NOT EXISTS sigmf_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observation_id INTEGER, + sigmf_data_path TEXT NOT NULL, sigmf_meta_path TEXT NOT NULL, size_bytes INTEGER DEFAULT 0, sample_rate INTEGER, center_freq_hz INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE SET NULL - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS ground_station_outputs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - observation_id INTEGER, - norad_id INTEGER, - output_type TEXT NOT NULL, - backend TEXT, - file_path TEXT NOT NULL, - preview_path TEXT, - metadata_json TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS ground_station_decode_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - observation_id INTEGER, - norad_id INTEGER, - backend TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'queued', - input_path TEXT, - output_dir TEXT, - error_message TEXT, - details_json TEXT, - started_at TIMESTAMP, - completed_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE - ) - ''') + FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE SET NULL + ) + """) - conn.execute(''' + conn.execute(""" + CREATE TABLE IF NOT EXISTS ground_station_outputs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observation_id INTEGER, + norad_id INTEGER, + output_type TEXT NOT NULL, + backend TEXT, + file_path TEXT NOT NULL, + preview_path TEXT, + metadata_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE + ) + """) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS ground_station_decode_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observation_id INTEGER, + norad_id INTEGER, + backend TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + input_path TEXT, + output_dir TEXT, + error_message TEXT, + details_json TEXT, + started_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE + ) + """) + + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_gs_observations_norad ON ground_station_observations(norad_id, created_at) - ''') + """) - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_gs_events_observation - ON ground_station_events(observation_id, timestamp) - ''') - - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_gs_outputs_observation - ON ground_station_outputs(observation_id, created_at) - ''') - - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_gs_decode_jobs_observation - ON ground_station_decode_jobs(observation_id, created_at) - ''') - - # Lightweight schema migrations for existing installs. - profile_cols = { - row['name'] for row in conn.execute('PRAGMA table_info(observation_profiles)') - } - if 'tasks_json' not in profile_cols: - conn.execute('ALTER TABLE observation_profiles ADD COLUMN tasks_json TEXT') - - logger.info("Database initialized successfully") + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_gs_events_observation + ON ground_station_events(observation_id, timestamp) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_gs_outputs_observation + ON ground_station_outputs(observation_id, created_at) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_gs_decode_jobs_observation + ON ground_station_decode_jobs(observation_id, created_at) + """) + + # Lightweight schema migrations for existing installs. + profile_cols = {row["name"] for row in conn.execute("PRAGMA table_info(observation_profiles)")} + if "tasks_json" not in profile_cols: + conn.execute("ALTER TABLE observation_profiles ADD COLUMN tasks_json TEXT") + + logger.info("Database initialized successfully") def close_db() -> None: - """Close the thread-local database connection.""" - if hasattr(_local, 'connection') and _local.connection is not None: + """Close the per-greenlet (or per-thread) database connection.""" + if hasattr(_local, "connection") and _local.connection is not None: _local.connection.close() _local.connection = None @@ -786,6 +794,7 @@ def close_db() -> None: # Settings Functions # ============================================================================= + def get_setting(key: str, default: Any = None) -> Any: """ Get a setting value by key. @@ -799,29 +808,26 @@ def get_setting(key: str, default: Any = None) -> Any: """ try: with get_db() as conn: - cursor = conn.execute( - 'SELECT value, value_type FROM settings WHERE key = ?', - (key,) - ) + cursor = conn.execute("SELECT value, value_type FROM settings WHERE key = ?", (key,)) row = cursor.fetchone() if row is None: return default - value, value_type = row['value'], row['value_type'] + value, value_type = row["value"], row["value_type"] # Convert based on type - if value_type == 'json': + if value_type == "json": try: return json.loads(value) except json.JSONDecodeError: return default - elif value_type == 'int': + elif value_type == "int": return int(value) - elif value_type == 'float': + elif value_type == "float": return float(value) - elif value_type == 'bool': - return value.lower() in ('true', '1', 'yes') + elif value_type == "bool": + return value.lower() in ("true", "1", "yes") else: return value except sqlite3.OperationalError: @@ -839,30 +845,33 @@ def set_setting(key: str, value: Any) -> None: """ # Determine value type and string representation if isinstance(value, bool): - value_type = 'bool' - str_value = 'true' if value else 'false' + value_type = "bool" + str_value = "true" if value else "false" elif isinstance(value, int): - value_type = 'int' + value_type = "int" str_value = str(value) elif isinstance(value, float): - value_type = 'float' + value_type = "float" str_value = str(value) elif isinstance(value, (dict, list)): - value_type = 'json' + value_type = "json" str_value = json.dumps(value) else: - value_type = 'string' + value_type = "string" str_value = str(value) with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO settings (key, value, value_type, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, value_type = excluded.value_type, updated_at = CURRENT_TIMESTAMP - ''', (key, str_value, value_type)) + """, + (key, str_value, value_type), + ) def delete_setting(key: str) -> bool: @@ -876,30 +885,30 @@ def delete_setting(key: str) -> bool: True if setting was deleted, False if not found """ with get_db() as conn: - cursor = conn.execute('DELETE FROM settings WHERE key = ?', (key,)) + cursor = conn.execute("DELETE FROM settings WHERE key = ?", (key,)) return cursor.rowcount > 0 def get_all_settings() -> dict[str, Any]: """Get all settings as a dictionary.""" with get_db() as conn: - cursor = conn.execute('SELECT key, value, value_type FROM settings') + cursor = conn.execute("SELECT key, value, value_type FROM settings") settings = {} for row in cursor: - key, value, value_type = row['key'], row['value'], row['value_type'] + key, value, value_type = row["key"], row["value"], row["value_type"] - if value_type == 'json': + if value_type == "json": try: settings[key] = json.loads(value) except json.JSONDecodeError: settings[key] = value - elif value_type == 'int': + elif value_type == "int": settings[key] = int(value) - elif value_type == 'float': + elif value_type == "float": settings[key] = float(value) - elif value_type == 'bool': - settings[key] = value.lower() in ('true', '1', 'yes') + elif value_type == "bool": + settings[key] = value.lower() in ("true", "1", "yes") else: settings[key] = value @@ -910,26 +919,20 @@ def get_all_settings() -> dict[str, Any]: # Signal History Functions # ============================================================================= -def add_signal_reading( - mode: str, - device_id: str, - signal_strength: float, - metadata: dict | None = None -) -> None: + +def add_signal_reading(mode: str, device_id: str, signal_strength: float, metadata: dict | None = None) -> None: """Add a signal strength reading.""" with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO signal_history (mode, device_id, signal_strength, metadata) VALUES (?, ?, ?, ?) - ''', (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None)) + """, + (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None), + ) -def get_signal_history( - mode: str, - device_id: str, - limit: int = 100, - since_minutes: int = 60 -) -> list[dict]: +def get_signal_history(mode: str, device_id: str, limit: int = 100, since_minutes: int = 60) -> list[dict]: """ Get signal history for a device. @@ -943,22 +946,27 @@ def get_signal_history( List of signal readings with timestamp """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT signal_strength, timestamp, metadata FROM signal_history WHERE mode = ? AND device_id = ? AND timestamp > datetime('now', ?) ORDER BY timestamp DESC LIMIT ? - ''', (mode, device_id, f'-{since_minutes} minutes', limit)) + """, + (mode, device_id, f"-{since_minutes} minutes", limit), + ) results = [] for row in cursor: - results.append({ - 'signal': row['signal_strength'], - 'timestamp': row['timestamp'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None - }) + results.append( + { + "signal": row["signal_strength"], + "timestamp": row["timestamp"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, + } + ) return list(reversed(results)) # Return in chronological order @@ -974,10 +982,13 @@ def cleanup_old_signal_history(max_age_hours: int = 24) -> int: Number of deleted entries """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM signal_history WHERE timestamp < datetime('now', ?) - ''', (f'-{max_age_hours} hours',)) + """, + (f"-{max_age_hours} hours",), + ) return cursor.rowcount @@ -985,44 +996,48 @@ def cleanup_old_signal_history(max_age_hours: int = 24) -> int: # Device Correlation Functions # ============================================================================= -def add_correlation( - wifi_mac: str, - bt_mac: str, - confidence: float, - metadata: dict | None = None -) -> None: + +def add_correlation(wifi_mac: str, bt_mac: str, confidence: float, metadata: dict | None = None) -> None: """Add or update a device correlation.""" with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO device_correlations (wifi_mac, bt_mac, confidence, metadata, last_seen) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(wifi_mac, bt_mac) DO UPDATE SET confidence = excluded.confidence, last_seen = CURRENT_TIMESTAMP, metadata = excluded.metadata - ''', (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None)) + """, + (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None), + ) def get_correlations(min_confidence: float = 0.5) -> list[dict]: """Get all device correlations above minimum confidence.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT wifi_mac, bt_mac, confidence, first_seen, last_seen, metadata FROM device_correlations WHERE confidence >= ? ORDER BY confidence DESC - ''', (min_confidence,)) + """, + (min_confidence,), + ) results = [] for row in cursor: - results.append({ - 'wifi_mac': row['wifi_mac'], - 'bt_mac': row['bt_mac'], - 'confidence': row['confidence'], - 'first_seen': row['first_seen'], - 'last_seen': row['last_seen'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None - }) + results.append( + { + "wifi_mac": row["wifi_mac"], + "bt_mac": row["bt_mac"], + "confidence": row["confidence"], + "first_seen": row["first_seen"], + "last_seen": row["last_seen"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, + } + ) return results @@ -1031,6 +1046,7 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]: # TSCM Functions # ============================================================================= + def create_tscm_baseline( name: str, location: str | None = None, @@ -1039,7 +1055,7 @@ def create_tscm_baseline( wifi_clients: list | None = None, bt_devices: list | None = None, rf_frequencies: list | None = None, - gps_coords: dict | None = None + gps_coords: dict | None = None, ) -> int: """ Create a new TSCM baseline. @@ -1048,57 +1064,63 @@ def create_tscm_baseline( The ID of the created baseline """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_baselines (name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - location, - description, - json.dumps(wifi_networks) if wifi_networks else None, - json.dumps(wifi_clients) if wifi_clients else None, - json.dumps(bt_devices) if bt_devices else None, - json.dumps(rf_frequencies) if rf_frequencies else None, - json.dumps(gps_coords) if gps_coords else None - )) + """, + ( + name, + location, + description, + json.dumps(wifi_networks) if wifi_networks else None, + json.dumps(wifi_clients) if wifi_clients else None, + json.dumps(bt_devices) if bt_devices else None, + json.dumps(rf_frequencies) if rf_frequencies else None, + json.dumps(gps_coords) if gps_coords else None, + ), + ) return cursor.lastrowid def get_tscm_baseline(baseline_id: int) -> dict | None: """Get a specific TSCM baseline by ID.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_baselines WHERE id = ? - ''', (baseline_id,)) + """, + (baseline_id,), + ) row = cursor.fetchone() if row is None: return None return { - 'id': row['id'], - 'name': row['name'], - 'location': row['location'], - 'description': row['description'], - 'created_at': row['created_at'], - 'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [], - 'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [], - 'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [], - 'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [], - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, - 'is_active': bool(row['is_active']) + "id": row["id"], + "name": row["name"], + "location": row["location"], + "description": row["description"], + "created_at": row["created_at"], + "wifi_networks": json.loads(row["wifi_networks"]) if row["wifi_networks"] else [], + "wifi_clients": json.loads(row["wifi_clients"]) if row["wifi_clients"] else [], + "bt_devices": json.loads(row["bt_devices"]) if row["bt_devices"] else [], + "rf_frequencies": json.loads(row["rf_frequencies"]) if row["rf_frequencies"] else [], + "gps_coords": json.loads(row["gps_coords"]) if row["gps_coords"] else None, + "is_active": bool(row["is_active"]), } def get_all_tscm_baselines() -> list[dict]: """Get all TSCM baselines.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT id, name, location, description, created_at, is_active FROM tscm_baselines ORDER BY created_at DESC - ''') + """) return [dict(row) for row in cursor] @@ -1106,27 +1128,24 @@ def get_all_tscm_baselines() -> list[dict]: def get_active_tscm_baseline() -> dict | None: """Get the currently active TSCM baseline.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT * FROM tscm_baselines WHERE is_active = 1 LIMIT 1 - ''') + """) row = cursor.fetchone() if row is None: return None - return get_tscm_baseline(row['id']) + return get_tscm_baseline(row["id"]) def set_active_tscm_baseline(baseline_id: int) -> bool: """Set a baseline as active (deactivates others).""" with get_db() as conn: # Deactivate all - conn.execute('UPDATE tscm_baselines SET is_active = 0') + conn.execute("UPDATE tscm_baselines SET is_active = 0") # Activate selected - cursor = conn.execute( - 'UPDATE tscm_baselines SET is_active = 1 WHERE id = ?', - (baseline_id,) - ) + cursor = conn.execute("UPDATE tscm_baselines SET is_active = 1 WHERE id = ?", (baseline_id,)) return cursor.rowcount > 0 @@ -1135,23 +1154,23 @@ def update_tscm_baseline( wifi_networks: list | None = None, wifi_clients: list | None = None, bt_devices: list | None = None, - rf_frequencies: list | None = None + rf_frequencies: list | None = None, ) -> bool: """Update baseline device lists.""" updates = [] params = [] if wifi_networks is not None: - updates.append('wifi_networks = ?') + updates.append("wifi_networks = ?") params.append(json.dumps(wifi_networks)) if wifi_clients is not None: - updates.append('wifi_clients = ?') + updates.append("wifi_clients = ?") params.append(json.dumps(wifi_clients)) if bt_devices is not None: - updates.append('bt_devices = ?') + updates.append("bt_devices = ?") params.append(json.dumps(bt_devices)) if rf_frequencies is not None: - updates.append('rf_frequencies = ?') + updates.append("rf_frequencies = ?") params.append(json.dumps(rf_frequencies)) if not updates: @@ -1160,20 +1179,14 @@ def update_tscm_baseline( params.append(baseline_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_baselines SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_baselines SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 def delete_tscm_baseline(baseline_id: int) -> bool: """Delete a TSCM baseline.""" with get_db() as conn: - cursor = conn.execute( - 'DELETE FROM tscm_baselines WHERE id = ?', - (baseline_id,) - ) + cursor = conn.execute("DELETE FROM tscm_baselines WHERE id = ?", (baseline_id,)) return cursor.rowcount > 0 @@ -1182,7 +1195,7 @@ def create_tscm_sweep( baseline_id: int | None = None, wifi_enabled: bool = True, bt_enabled: bool = True, - rf_enabled: bool = True + rf_enabled: bool = True, ) -> int: """ Create a new TSCM sweep session. @@ -1191,11 +1204,14 @@ def create_tscm_sweep( The ID of the created sweep """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_sweeps (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled) VALUES (?, ?, ?, ?, ?) - ''', (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled)) + """, + (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled), + ) return cursor.lastrowid @@ -1205,26 +1221,26 @@ def update_tscm_sweep( results: dict | None = None, anomalies: list | None = None, threats_found: int | None = None, - completed: bool = False + completed: bool = False, ) -> bool: """Update a TSCM sweep.""" updates = [] params = [] if status is not None: - updates.append('status = ?') + updates.append("status = ?") params.append(status) if results is not None: - updates.append('results = ?') + updates.append("results = ?") params.append(json.dumps(results)) if anomalies is not None: - updates.append('anomalies = ?') + updates.append("anomalies = ?") params.append(json.dumps(anomalies)) if threats_found is not None: - updates.append('threats_found = ?') + updates.append("threats_found = ?") params.append(threats_found) if completed: - updates.append('completed_at = CURRENT_TIMESTAMP') + updates.append("completed_at = CURRENT_TIMESTAMP") if not updates: return False @@ -1232,35 +1248,32 @@ def update_tscm_sweep( params.append(sweep_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_sweeps SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_sweeps SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 def get_tscm_sweep(sweep_id: int) -> dict | None: """Get a specific TSCM sweep by ID.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM tscm_sweeps WHERE id = ?', (sweep_id,)) + cursor = conn.execute("SELECT * FROM tscm_sweeps WHERE id = ?", (sweep_id,)) row = cursor.fetchone() if row is None: return None return { - 'id': row['id'], - 'baseline_id': row['baseline_id'], - 'started_at': row['started_at'], - 'completed_at': row['completed_at'], - 'status': row['status'], - 'sweep_type': row['sweep_type'], - 'wifi_enabled': bool(row['wifi_enabled']), - 'bt_enabled': bool(row['bt_enabled']), - 'rf_enabled': bool(row['rf_enabled']), - 'results': json.loads(row['results']) if row['results'] else None, - 'anomalies': json.loads(row['anomalies']) if row['anomalies'] else [], - 'threats_found': row['threats_found'] + "id": row["id"], + "baseline_id": row["baseline_id"], + "started_at": row["started_at"], + "completed_at": row["completed_at"], + "status": row["status"], + "sweep_type": row["sweep_type"], + "wifi_enabled": bool(row["wifi_enabled"]), + "bt_enabled": bool(row["bt_enabled"]), + "rf_enabled": bool(row["rf_enabled"]), + "results": json.loads(row["results"]) if row["results"] else None, + "anomalies": json.loads(row["anomalies"]) if row["anomalies"] else [], + "threats_found": row["threats_found"], } @@ -1274,7 +1287,7 @@ def add_tscm_threat( signal_strength: int | None = None, frequency: float | None = None, details: dict | None = None, - gps_coords: dict | None = None + gps_coords: dict | None = None, ) -> int: """ Add a detected threat to a TSCM sweep. @@ -1283,69 +1296,80 @@ def add_tscm_threat( The ID of the created threat """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_threats (sweep_id, threat_type, severity, source, identifier, name, signal_strength, frequency, details, gps_coords) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - sweep_id, threat_type, severity, source, identifier, name, - signal_strength, frequency, - json.dumps(details) if details else None, - json.dumps(gps_coords) if gps_coords else None - )) + """, + ( + sweep_id, + threat_type, + severity, + source, + identifier, + name, + signal_strength, + frequency, + json.dumps(details) if details else None, + json.dumps(gps_coords) if gps_coords else None, + ), + ) return cursor.lastrowid def get_tscm_threats( - sweep_id: int | None = None, - severity: str | None = None, - acknowledged: bool | None = None, - limit: int = 100 + sweep_id: int | None = None, severity: str | None = None, acknowledged: bool | None = None, limit: int = 100 ) -> list[dict]: """Get TSCM threats with optional filters.""" conditions = [] params = [] if sweep_id is not None: - conditions.append('sweep_id = ?') + conditions.append("sweep_id = ?") params.append(sweep_id) if severity is not None: - conditions.append('severity = ?') + conditions.append("severity = ?") params.append(severity) if acknowledged is not None: - conditions.append('acknowledged = ?') + conditions.append("acknowledged = ?") params.append(1 if acknowledged else 0) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_threats {where_clause} ORDER BY detected_at DESC LIMIT ? - ''', params) + """, + params, + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'sweep_id': row['sweep_id'], - 'detected_at': row['detected_at'], - 'threat_type': row['threat_type'], - 'severity': row['severity'], - 'source': row['source'], - 'identifier': row['identifier'], - 'name': row['name'], - 'signal_strength': row['signal_strength'], - 'frequency': row['frequency'], - 'details': json.loads(row['details']) if row['details'] else None, - 'acknowledged': bool(row['acknowledged']), - 'notes': row['notes'], - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None - }) + results.append( + { + "id": row["id"], + "sweep_id": row["sweep_id"], + "detected_at": row["detected_at"], + "threat_type": row["threat_type"], + "severity": row["severity"], + "source": row["source"], + "identifier": row["identifier"], + "name": row["name"], + "signal_strength": row["signal_strength"], + "frequency": row["frequency"], + "details": json.loads(row["details"]) if row["details"] else None, + "acknowledged": bool(row["acknowledged"]), + "notes": row["notes"], + "gps_coords": json.loads(row["gps_coords"]) if row["gps_coords"] else None, + } + ) return results @@ -1355,31 +1379,27 @@ def acknowledge_tscm_threat(threat_id: int, notes: str | None = None) -> bool: with get_db() as conn: if notes: cursor = conn.execute( - 'UPDATE tscm_threats SET acknowledged = 1, notes = ? WHERE id = ?', - (notes, threat_id) + "UPDATE tscm_threats SET acknowledged = 1, notes = ? WHERE id = ?", (notes, threat_id) ) else: - cursor = conn.execute( - 'UPDATE tscm_threats SET acknowledged = 1 WHERE id = ?', - (threat_id,) - ) + cursor = conn.execute("UPDATE tscm_threats SET acknowledged = 1 WHERE id = ?", (threat_id,)) return cursor.rowcount > 0 def get_tscm_threat_summary() -> dict: """Get summary counts of threats by severity.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT severity, COUNT(*) as count FROM tscm_threats WHERE acknowledged = 0 GROUP BY severity - ''') + """) - summary = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'total': 0} + summary = {"critical": 0, "high": 0, "medium": 0, "low": 0, "total": 0} for row in cursor: - summary[row['severity']] = row['count'] - summary['total'] += row['count'] + summary[row["severity"]] = row["count"] + summary["total"] += row["count"] return summary @@ -1388,6 +1408,7 @@ def get_tscm_threat_summary() -> dict: # TSCM Device Timeline Functions # ============================================================================= + def add_device_timeline_entry( device_identifier: str, protocol: str, @@ -1396,60 +1417,73 @@ def add_device_timeline_entry( presence: bool = True, channel: int | None = None, frequency: float | None = None, - attributes: dict | None = None + attributes: dict | None = None, ) -> int: """Add a device timeline observation entry.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_device_timelines (device_identifier, protocol, sweep_id, rssi, presence, channel, frequency, attributes) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - device_identifier, protocol, sweep_id, rssi, presence, - channel, frequency, json.dumps(attributes) if attributes else None - )) + """, + ( + device_identifier, + protocol, + sweep_id, + rssi, + presence, + channel, + frequency, + json.dumps(attributes) if attributes else None, + ), + ) return cursor.lastrowid -def get_device_timeline( - device_identifier: str, - limit: int = 100, - since_hours: int = 24 -) -> list[dict]: +def get_device_timeline(device_identifier: str, limit: int = 100, since_hours: int = 24) -> list[dict]: """Get timeline entries for a device.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_device_timelines WHERE device_identifier = ? AND timestamp > datetime('now', ?) ORDER BY timestamp DESC LIMIT ? - ''', (device_identifier, f'-{since_hours} hours', limit)) + """, + (device_identifier, f"-{since_hours} hours", limit), + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'device_identifier': row['device_identifier'], - 'protocol': row['protocol'], - 'sweep_id': row['sweep_id'], - 'timestamp': row['timestamp'], - 'rssi': row['rssi'], - 'presence': bool(row['presence']), - 'channel': row['channel'], - 'frequency': row['frequency'], - 'attributes': json.loads(row['attributes']) if row['attributes'] else None - }) + results.append( + { + "id": row["id"], + "device_identifier": row["device_identifier"], + "protocol": row["protocol"], + "sweep_id": row["sweep_id"], + "timestamp": row["timestamp"], + "rssi": row["rssi"], + "presence": bool(row["presence"]), + "channel": row["channel"], + "frequency": row["frequency"], + "attributes": json.loads(row["attributes"]) if row["attributes"] else None, + } + ) return list(reversed(results)) def cleanup_old_timeline_entries(max_age_hours: int = 72) -> int: """Remove old timeline entries.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM tscm_device_timelines WHERE timestamp < datetime('now', ?) - ''', (f'-{max_age_hours} hours',)) + """, + (f"-{max_age_hours} hours",), + ) return cursor.rowcount @@ -1457,20 +1491,22 @@ def cleanup_old_timeline_entries(max_age_hours: int = 72) -> int: # TSCM Known-Good Registry Functions # ============================================================================= + def add_known_device( identifier: str, protocol: str, name: str | None = None, description: str | None = None, location: str | None = None, - scope: str = 'global', + scope: str = "global", added_by: str | None = None, score_modifier: int = -2, - metadata: dict | None = None + metadata: dict | None = None, ) -> int: """Add a device to the known-good registry.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_known_devices (identifier, protocol, name, description, location, scope, added_by, score_modifier, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -1482,77 +1518,83 @@ def add_known_device( score_modifier = excluded.score_modifier, metadata = excluded.metadata, last_verified = CURRENT_TIMESTAMP - ''', ( - identifier.upper(), protocol, name, description, location, - scope, added_by, score_modifier, json.dumps(metadata) if metadata else None - )) + """, + ( + identifier.upper(), + protocol, + name, + description, + location, + scope, + added_by, + score_modifier, + json.dumps(metadata) if metadata else None, + ), + ) return cursor.lastrowid def get_known_device(identifier: str) -> dict | None: """Get a known device by identifier.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM tscm_known_devices WHERE identifier = ?', - (identifier.upper(),) - ) + cursor = conn.execute("SELECT * FROM tscm_known_devices WHERE identifier = ?", (identifier.upper(),)) row = cursor.fetchone() if not row: return None return { - 'id': row['id'], - 'identifier': row['identifier'], - 'protocol': row['protocol'], - 'name': row['name'], - 'description': row['description'], - 'location': row['location'], - 'scope': row['scope'], - 'added_at': row['added_at'], - 'added_by': row['added_by'], - 'last_verified': row['last_verified'], - 'score_modifier': row['score_modifier'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None + "id": row["id"], + "identifier": row["identifier"], + "protocol": row["protocol"], + "name": row["name"], + "description": row["description"], + "location": row["location"], + "scope": row["scope"], + "added_at": row["added_at"], + "added_by": row["added_by"], + "last_verified": row["last_verified"], + "score_modifier": row["score_modifier"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, } -def get_all_known_devices( - location: str | None = None, - scope: str | None = None -) -> list[dict]: +def get_all_known_devices(location: str | None = None, scope: str | None = None) -> list[dict]: """Get all known devices, optionally filtered by location or scope.""" conditions = [] params = [] if location: - conditions.append('(location = ? OR scope = ?)') - params.extend([location, 'global']) + conditions.append("(location = ? OR scope = ?)") + params.extend([location, "global"]) if scope: - conditions.append('scope = ?') + conditions.append("scope = ?") params.append(scope) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_known_devices {where_clause} ORDER BY added_at DESC - ''', params) + """, + params, + ) return [ { - 'id': row['id'], - 'identifier': row['identifier'], - 'protocol': row['protocol'], - 'name': row['name'], - 'description': row['description'], - 'location': row['location'], - 'scope': row['scope'], - 'added_at': row['added_at'], - 'added_by': row['added_by'], - 'last_verified': row['last_verified'], - 'score_modifier': row['score_modifier'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None + "id": row["id"], + "identifier": row["identifier"], + "protocol": row["protocol"], + "name": row["name"], + "description": row["description"], + "location": row["location"], + "scope": row["scope"], + "added_at": row["added_at"], + "added_by": row["added_by"], + "last_verified": row["last_verified"], + "score_modifier": row["score_modifier"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, } for row in cursor ] @@ -1561,10 +1603,7 @@ def get_all_known_devices( def delete_known_device(identifier: str) -> bool: """Remove a device from the known-good registry.""" with get_db() as conn: - cursor = conn.execute( - 'DELETE FROM tscm_known_devices WHERE identifier = ?', - (identifier.upper(),) - ) + cursor = conn.execute("DELETE FROM tscm_known_devices WHERE identifier = ?", (identifier.upper(),)) return cursor.rowcount > 0 @@ -1572,10 +1611,11 @@ def delete_known_device(identifier: str) -> bool: # TSCM Schedule Functions # ============================================================================= + def create_tscm_schedule( name: str, cron_expression: str, - sweep_type: str = 'standard', + sweep_type: str = "standard", baseline_id: int | None = None, zone_name: str | None = None, enabled: bool = True, @@ -1586,59 +1626,59 @@ def create_tscm_schedule( ) -> int: """Create a new TSCM sweep schedule.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_schedules (name, baseline_id, zone_name, cron_expression, sweep_type, enabled, last_run, next_run, notify_on_threat, notify_email) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - baseline_id, - zone_name, - cron_expression, - sweep_type, - 1 if enabled else 0, - last_run, - next_run, - 1 if notify_on_threat else 0, - notify_email, - )) + """, + ( + name, + baseline_id, + zone_name, + cron_expression, + sweep_type, + 1 if enabled else 0, + last_run, + next_run, + 1 if notify_on_threat else 0, + notify_email, + ), + ) return cursor.lastrowid def get_tscm_schedule(schedule_id: int) -> dict | None: """Get a TSCM schedule by ID.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM tscm_schedules WHERE id = ?', - (schedule_id,) - ) + cursor = conn.execute("SELECT * FROM tscm_schedules WHERE id = ?", (schedule_id,)) row = cursor.fetchone() return dict(row) if row else None -def get_all_tscm_schedules( - enabled: bool | None = None, - limit: int = 200 -) -> list[dict]: +def get_all_tscm_schedules(enabled: bool | None = None, limit: int = 200) -> list[dict]: """Get all TSCM schedules.""" conditions = [] params = [] if enabled is not None: - conditions.append('enabled = ?') + conditions.append("enabled = ?") params.append(1 if enabled else 0) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_schedules {where_clause} ORDER BY id DESC LIMIT ? - ''', params) + """, + params, + ) return [dict(row) for row in cursor] @@ -1651,26 +1691,20 @@ def update_tscm_schedule(schedule_id: int, **fields) -> bool: params = [] for key, value in fields.items(): - updates.append(f'{key} = ?') + updates.append(f"{key} = ?") params.append(value) params.append(schedule_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_schedules SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 def delete_tscm_schedule(schedule_id: int) -> bool: """Delete a TSCM schedule.""" with get_db() as conn: - cursor = conn.execute( - 'DELETE FROM tscm_schedules WHERE id = ?', - (schedule_id,) - ) + cursor = conn.execute("DELETE FROM tscm_schedules WHERE id = ?", (schedule_id,)) return cursor.rowcount > 0 @@ -1678,23 +1712,23 @@ def is_known_good_device(identifier: str, location: str | None = None) -> dict | """Check if a device is in the known-good registry for a location.""" with get_db() as conn: if location: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_known_devices WHERE identifier = ? AND (location = ? OR scope = 'global') - ''', (identifier.upper(), location)) - else: - cursor = conn.execute( - 'SELECT * FROM tscm_known_devices WHERE identifier = ?', - (identifier.upper(),) + """, + (identifier.upper(), location), ) + else: + cursor = conn.execute("SELECT * FROM tscm_known_devices WHERE identifier = ?", (identifier.upper(),)) row = cursor.fetchone() if not row: return None return { - 'identifier': row['identifier'], - 'name': row['name'], - 'score_modifier': row['score_modifier'], - 'scope': row['scope'] + "identifier": row["identifier"], + "name": row["name"], + "score_modifier": row["score_modifier"], + "scope": row["scope"], } @@ -1702,103 +1736,115 @@ def is_known_good_device(identifier: str, location: str | None = None) -> dict | # TSCM Case Functions # ============================================================================= + def create_tscm_case( name: str, description: str | None = None, location: str | None = None, - priority: str = 'normal', + priority: str = "normal", created_by: str | None = None, - metadata: dict | None = None + metadata: dict | None = None, ) -> int: """Create a new TSCM case.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_cases (name, description, location, priority, created_by, metadata) VALUES (?, ?, ?, ?, ?, ?) - ''', (name, description, location, priority, created_by, - json.dumps(metadata) if metadata else None)) + """, + (name, description, location, priority, created_by, json.dumps(metadata) if metadata else None), + ) return cursor.lastrowid def get_tscm_case(case_id: int) -> dict | None: """Get a TSCM case by ID.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM tscm_cases WHERE id = ?', (case_id,)) + cursor = conn.execute("SELECT * FROM tscm_cases WHERE id = ?", (case_id,)) row = cursor.fetchone() if not row: return None case = { - 'id': row['id'], - 'name': row['name'], - 'description': row['description'], - 'location': row['location'], - 'status': row['status'], - 'priority': row['priority'], - 'created_at': row['created_at'], - 'updated_at': row['updated_at'], - 'closed_at': row['closed_at'], - 'created_by': row['created_by'], - 'assigned_to': row['assigned_to'], - 'notes': row['notes'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None, - 'sweeps': [], - 'threats': [], - 'case_notes': [] + "id": row["id"], + "name": row["name"], + "description": row["description"], + "location": row["location"], + "status": row["status"], + "priority": row["priority"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "closed_at": row["closed_at"], + "created_by": row["created_by"], + "assigned_to": row["assigned_to"], + "notes": row["notes"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, + "sweeps": [], + "threats": [], + "case_notes": [], } # Get linked sweeps - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT s.* FROM tscm_sweeps s JOIN tscm_case_sweeps cs ON s.id = cs.sweep_id WHERE cs.case_id = ? ORDER BY s.started_at DESC - ''', (case_id,)) - case['sweeps'] = [dict(row) for row in cursor] + """, + (case_id,), + ) + case["sweeps"] = [dict(row) for row in cursor] # Get linked threats - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT t.* FROM tscm_threats t JOIN tscm_case_threats ct ON t.id = ct.threat_id WHERE ct.case_id = ? ORDER BY t.detected_at DESC - ''', (case_id,)) - case['threats'] = [dict(row) for row in cursor] + """, + (case_id,), + ) + case["threats"] = [dict(row) for row in cursor] # Get case notes - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_case_notes WHERE case_id = ? ORDER BY created_at DESC - ''', (case_id,)) - case['case_notes'] = [dict(row) for row in cursor] + """, + (case_id,), + ) + case["case_notes"] = [dict(row) for row in cursor] return case -def get_all_tscm_cases( - status: str | None = None, - limit: int = 50 -) -> list[dict]: +def get_all_tscm_cases(status: str | None = None, limit: int = 50) -> list[dict]: """Get all TSCM cases.""" conditions = [] params = [] if status: - conditions.append('status = ?') + conditions.append("status = ?") params.append(status) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_cases {where_clause} ORDER BY updated_at DESC LIMIT ? - ''', params) + """, + params, + ) return [dict(row) for row in cursor] @@ -1807,34 +1853,31 @@ def update_tscm_case( status: str | None = None, priority: str | None = None, assigned_to: str | None = None, - notes: str | None = None + notes: str | None = None, ) -> bool: """Update a TSCM case.""" - updates = ['updated_at = CURRENT_TIMESTAMP'] + updates = ["updated_at = CURRENT_TIMESTAMP"] params = [] if status: - updates.append('status = ?') + updates.append("status = ?") params.append(status) - if status == 'closed': - updates.append('closed_at = CURRENT_TIMESTAMP') + if status == "closed": + updates.append("closed_at = CURRENT_TIMESTAMP") if priority: - updates.append('priority = ?') + updates.append("priority = ?") params.append(priority) if assigned_to is not None: - updates.append('assigned_to = ?') + updates.append("assigned_to = ?") params.append(assigned_to) if notes is not None: - updates.append('notes = ?') + updates.append("notes = ?") params.append(notes) params.append(case_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_cases SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_cases SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 @@ -1842,14 +1885,14 @@ def add_sweep_to_case(case_id: int, sweep_id: int) -> bool: """Link a sweep to a case.""" with get_db() as conn: try: - conn.execute(''' + conn.execute( + """ INSERT INTO tscm_case_sweeps (case_id, sweep_id) VALUES (?, ?) - ''', (case_id, sweep_id)) - conn.execute( - 'UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', - (case_id,) + """, + (case_id, sweep_id), ) + conn.execute("UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (case_id,)) return True except sqlite3.IntegrityError: return False @@ -1859,35 +1902,30 @@ def add_threat_to_case(case_id: int, threat_id: int) -> bool: """Link a threat to a case.""" with get_db() as conn: try: - conn.execute(''' + conn.execute( + """ INSERT INTO tscm_case_threats (case_id, threat_id) VALUES (?, ?) - ''', (case_id, threat_id)) - conn.execute( - 'UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', - (case_id,) + """, + (case_id, threat_id), ) + conn.execute("UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (case_id,)) return True except sqlite3.IntegrityError: return False -def add_case_note( - case_id: int, - content: str, - note_type: str = 'general', - created_by: str | None = None -) -> int: +def add_case_note(case_id: int, content: str, note_type: str = "general", created_by: str | None = None) -> int: """Add a note to a case.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_case_notes (case_id, content, note_type, created_by) VALUES (?, ?, ?, ?) - ''', (case_id, content, note_type, created_by)) - conn.execute( - 'UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', - (case_id,) + """, + (case_id, content, note_type, created_by), ) + conn.execute("UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (case_id,)) return cursor.lastrowid @@ -1895,29 +1933,33 @@ def add_case_note( # TSCM Meeting Window Functions # ============================================================================= + def start_meeting_window( - sweep_id: int | None = None, - name: str | None = None, - location: str | None = None, - notes: str | None = None + sweep_id: int | None = None, name: str | None = None, location: str | None = None, notes: str | None = None ) -> int: """Start a meeting window.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_meeting_windows (sweep_id, name, start_time, location, notes) VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?) - ''', (sweep_id, name, location, notes)) + """, + (sweep_id, name, location, notes), + ) return cursor.lastrowid def end_meeting_window(meeting_id: int) -> bool: """End a meeting window.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ UPDATE tscm_meeting_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ? AND end_time IS NULL - ''', (meeting_id,)) + """, + (meeting_id,), + ) return cursor.rowcount > 0 @@ -1925,17 +1967,20 @@ def get_active_meeting_window(sweep_id: int | None = None) -> dict | None: """Get currently active meeting window.""" with get_db() as conn: if sweep_id: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_meeting_windows WHERE sweep_id = ? AND end_time IS NULL ORDER BY start_time DESC LIMIT 1 - ''', (sweep_id,)) + """, + (sweep_id,), + ) else: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT * FROM tscm_meeting_windows WHERE end_time IS NULL ORDER BY start_time DESC LIMIT 1 - ''') + """) row = cursor.fetchone() if row: return dict(row) @@ -1945,11 +1990,14 @@ def get_active_meeting_window(sweep_id: int | None = None) -> dict | None: def get_meeting_windows(sweep_id: int) -> list[dict]: """Get all meeting windows for a sweep.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_meeting_windows WHERE sweep_id = ? ORDER BY start_time - ''', (sweep_id,)) + """, + (sweep_id,), + ) return [dict(row) for row in cursor] @@ -1957,40 +2005,36 @@ def get_meeting_windows(sweep_id: int) -> list[dict]: # TSCM Sweep Capabilities Functions # ============================================================================= -def save_sweep_capabilities( - sweep_id: int, - capabilities: dict, - limitations: list[str] | None = None -) -> int: + +def save_sweep_capabilities(sweep_id: int, capabilities: dict, limitations: list[str] | None = None) -> int: """Save sweep capabilities snapshot.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_sweep_capabilities (sweep_id, capabilities, limitations) VALUES (?, ?, ?) ON CONFLICT(sweep_id) DO UPDATE SET capabilities = excluded.capabilities, limitations = excluded.limitations, recorded_at = CURRENT_TIMESTAMP - ''', (sweep_id, json.dumps(capabilities), - json.dumps(limitations) if limitations else None)) + """, + (sweep_id, json.dumps(capabilities), json.dumps(limitations) if limitations else None), + ) return cursor.lastrowid def get_sweep_capabilities(sweep_id: int) -> dict | None: """Get capabilities for a sweep.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM tscm_sweep_capabilities WHERE sweep_id = ?', - (sweep_id,) - ) + cursor = conn.execute("SELECT * FROM tscm_sweep_capabilities WHERE sweep_id = ?", (sweep_id,)) row = cursor.fetchone() if not row: return None return { - 'sweep_id': row['sweep_id'], - 'capabilities': json.loads(row['capabilities']), - 'limitations': json.loads(row['limitations']) if row['limitations'] else [], - 'recorded_at': row['recorded_at'] + "sweep_id": row["sweep_id"], + "capabilities": json.loads(row["capabilities"]), + "limitations": json.loads(row["limitations"]) if row["limitations"] else [], + "recorded_at": row["recorded_at"], } @@ -1998,6 +2042,7 @@ def get_sweep_capabilities(sweep_id: int) -> dict | None: # DSC (Digital Selective Calling) Functions # ============================================================================= + def store_dsc_alert( source_mmsi: str, format_code: str, @@ -2007,7 +2052,7 @@ def store_dsc_alert( nature_of_distress: str | None = None, latitude: float | None = None, longitude: float | None = None, - raw_message: str | None = None + raw_message: str | None = None, ) -> int: """ Store a DSC alert (typically DISTRESS or URGENCY) to permanent storage. @@ -2016,15 +2061,25 @@ def store_dsc_alert( The ID of the created alert """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO dsc_alerts (source_mmsi, source_name, dest_mmsi, format_code, category, nature_of_distress, latitude, longitude, raw_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - source_mmsi, source_name, dest_mmsi, format_code, category, - nature_of_distress, latitude, longitude, raw_message - )) + """, + ( + source_mmsi, + source_name, + dest_mmsi, + format_code, + category, + nature_of_distress, + latitude, + longitude, + raw_message, + ), + ) return cursor.lastrowid @@ -2033,7 +2088,7 @@ def get_dsc_alerts( acknowledged: bool | None = None, source_mmsi: str | None = None, limit: int = 100, - offset: int = 0 + offset: int = 0, ) -> list[dict]: """ Get DSC alerts with optional filters. @@ -2052,70 +2107,72 @@ def get_dsc_alerts( params = [] if category is not None: - conditions.append('category = ?') + conditions.append("category = ?") params.append(category) if acknowledged is not None: - conditions.append('acknowledged = ?') + conditions.append("acknowledged = ?") params.append(1 if acknowledged else 0) if source_mmsi is not None: - conditions.append('source_mmsi = ?') + conditions.append("source_mmsi = ?") params.append(source_mmsi) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.extend([limit, offset]) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM dsc_alerts {where_clause} ORDER BY received_at DESC LIMIT ? OFFSET ? - ''', params) + """, + params, + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'received_at': row['received_at'], - 'source_mmsi': row['source_mmsi'], - 'source_name': row['source_name'], - 'dest_mmsi': row['dest_mmsi'], - 'format_code': row['format_code'], - 'category': row['category'], - 'nature_of_distress': row['nature_of_distress'], - 'latitude': row['latitude'], - 'longitude': row['longitude'], - 'raw_message': row['raw_message'], - 'acknowledged': bool(row['acknowledged']), - 'notes': row['notes'] - }) + results.append( + { + "id": row["id"], + "received_at": row["received_at"], + "source_mmsi": row["source_mmsi"], + "source_name": row["source_name"], + "dest_mmsi": row["dest_mmsi"], + "format_code": row["format_code"], + "category": row["category"], + "nature_of_distress": row["nature_of_distress"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "raw_message": row["raw_message"], + "acknowledged": bool(row["acknowledged"]), + "notes": row["notes"], + } + ) return results def get_dsc_alert(alert_id: int) -> dict | None: """Get a specific DSC alert by ID.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM dsc_alerts WHERE id = ?', - (alert_id,) - ) + cursor = conn.execute("SELECT * FROM dsc_alerts WHERE id = ?", (alert_id,)) row = cursor.fetchone() if not row: return None return { - 'id': row['id'], - 'received_at': row['received_at'], - 'source_mmsi': row['source_mmsi'], - 'source_name': row['source_name'], - 'dest_mmsi': row['dest_mmsi'], - 'format_code': row['format_code'], - 'category': row['category'], - 'nature_of_distress': row['nature_of_distress'], - 'latitude': row['latitude'], - 'longitude': row['longitude'], - 'raw_message': row['raw_message'], - 'acknowledged': bool(row['acknowledged']), - 'notes': row['notes'] + "id": row["id"], + "received_at": row["received_at"], + "source_mmsi": row["source_mmsi"], + "source_name": row["source_name"], + "dest_mmsi": row["dest_mmsi"], + "format_code": row["format_code"], + "category": row["category"], + "nature_of_distress": row["nature_of_distress"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "raw_message": row["raw_message"], + "acknowledged": bool(row["acknowledged"]), + "notes": row["notes"], } @@ -2132,34 +2189,28 @@ def acknowledge_dsc_alert(alert_id: int, notes: str | None = None) -> bool: """ with get_db() as conn: if notes: - cursor = conn.execute( - 'UPDATE dsc_alerts SET acknowledged = 1, notes = ? WHERE id = ?', - (notes, alert_id) - ) + cursor = conn.execute("UPDATE dsc_alerts SET acknowledged = 1, notes = ? WHERE id = ?", (notes, alert_id)) else: - cursor = conn.execute( - 'UPDATE dsc_alerts SET acknowledged = 1 WHERE id = ?', - (alert_id,) - ) + cursor = conn.execute("UPDATE dsc_alerts SET acknowledged = 1 WHERE id = ?", (alert_id,)) return cursor.rowcount > 0 def get_dsc_alert_summary() -> dict: """Get summary counts of DSC alerts by category.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT category, COUNT(*) as count FROM dsc_alerts WHERE acknowledged = 0 GROUP BY category - ''') + """) - summary = {'distress': 0, 'urgency': 0, 'safety': 0, 'routine': 0, 'total': 0} + summary = {"distress": 0, "urgency": 0, "safety": 0, "routine": 0, "total": 0} for row in cursor: - cat = row['category'].lower() + cat = row["category"].lower() if cat in summary: - summary[cat] = row['count'] - summary['total'] += row['count'] + summary[cat] = row["count"] + summary["total"] += row["count"] return summary @@ -2175,11 +2226,14 @@ def cleanup_old_dsc_alerts(max_age_days: int = 30) -> int: Number of deleted alerts """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM dsc_alerts WHERE acknowledged = 1 AND received_at < datetime('now', ?) - ''', (f'-{max_age_days} days',)) + """, + (f"-{max_age_days} days",), + ) return cursor.rowcount @@ -2187,6 +2241,7 @@ def cleanup_old_dsc_alerts(max_age_days: int = 30) -> int: # Remote Agent Functions (for distributed/controller mode) # ============================================================================= + def create_agent( name: str, base_url: str, @@ -2194,7 +2249,7 @@ def create_agent( description: str | None = None, capabilities: dict | None = None, interfaces: dict | None = None, - gps_coords: dict | None = None + gps_coords: dict | None = None, ) -> int: """ Create a new remote agent. @@ -2203,26 +2258,29 @@ def create_agent( The ID of the created agent """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO agents (name, base_url, api_key, description, capabilities, interfaces, gps_coords) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - base_url.rstrip('/'), - api_key, - description, - json.dumps(capabilities) if capabilities else None, - json.dumps(interfaces) if interfaces else None, - json.dumps(gps_coords) if gps_coords else None - )) + """, + ( + name, + base_url.rstrip("/"), + api_key, + description, + json.dumps(capabilities) if capabilities else None, + json.dumps(interfaces) if interfaces else None, + json.dumps(gps_coords) if gps_coords else None, + ), + ) return cursor.lastrowid def get_agent(agent_id: int) -> dict | None: """Get an agent by ID.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM agents WHERE id = ?', (agent_id,)) + cursor = conn.execute("SELECT * FROM agents WHERE id = ?", (agent_id,)) row = cursor.fetchone() if not row: return None @@ -2232,7 +2290,7 @@ def get_agent(agent_id: int) -> dict | None: def get_agent_by_name(name: str) -> dict | None: """Get an agent by name.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM agents WHERE name = ?', (name,)) + cursor = conn.execute("SELECT * FROM agents WHERE name = ?", (name,)) row = cursor.fetchone() if not row: return None @@ -2242,17 +2300,17 @@ def get_agent_by_name(name: str) -> dict | None: def _row_to_agent(row) -> dict: """Convert database row to agent dict.""" return { - 'id': row['id'], - 'name': row['name'], - 'base_url': row['base_url'], - 'description': row['description'], - 'api_key': row['api_key'], - 'capabilities': json.loads(row['capabilities']) if row['capabilities'] else None, - 'interfaces': json.loads(row['interfaces']) if row['interfaces'] else None, - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, - 'last_seen': row['last_seen'], - 'created_at': row['created_at'], - 'is_active': bool(row['is_active']) + "id": row["id"], + "name": row["name"], + "base_url": row["base_url"], + "description": row["description"], + "api_key": row["api_key"], + "capabilities": json.loads(row["capabilities"]) if row["capabilities"] else None, + "interfaces": json.loads(row["interfaces"]) if row["interfaces"] else None, + "gps_coords": json.loads(row["gps_coords"]) if row["gps_coords"] else None, + "last_seen": row["last_seen"], + "created_at": row["created_at"], + "is_active": bool(row["is_active"]), } @@ -2260,11 +2318,9 @@ def list_agents(active_only: bool = True) -> list[dict]: """Get all agents.""" with get_db() as conn: if active_only: - cursor = conn.execute( - 'SELECT * FROM agents WHERE is_active = 1 ORDER BY name' - ) + cursor = conn.execute("SELECT * FROM agents WHERE is_active = 1 ORDER BY name") else: - cursor = conn.execute('SELECT * FROM agents ORDER BY name') + cursor = conn.execute("SELECT * FROM agents ORDER BY name") return [_row_to_agent(row) for row in cursor] @@ -2277,35 +2333,35 @@ def update_agent( interfaces: dict | None = None, gps_coords: dict | None = None, is_active: bool | None = None, - update_last_seen: bool = False + update_last_seen: bool = False, ) -> bool: """Update an agent's fields.""" updates = [] params = [] if base_url is not None: - updates.append('base_url = ?') - params.append(base_url.rstrip('/')) + updates.append("base_url = ?") + params.append(base_url.rstrip("/")) if description is not None: - updates.append('description = ?') + updates.append("description = ?") params.append(description) if api_key is not None: - updates.append('api_key = ?') + updates.append("api_key = ?") params.append(api_key) if capabilities is not None: - updates.append('capabilities = ?') + updates.append("capabilities = ?") params.append(json.dumps(capabilities)) if interfaces is not None: - updates.append('interfaces = ?') + updates.append("interfaces = ?") params.append(json.dumps(interfaces)) if gps_coords is not None: - updates.append('gps_coords = ?') + updates.append("gps_coords = ?") params.append(json.dumps(gps_coords)) if is_active is not None: - updates.append('is_active = ?') + updates.append("is_active = ?") params.append(1 if is_active else 0) if update_last_seen: - updates.append('last_seen = CURRENT_TIMESTAMP') + updates.append("last_seen = CURRENT_TIMESTAMP") if not updates: return False @@ -2313,10 +2369,7 @@ def update_agent( params.append(agent_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE agents SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE agents SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 @@ -2324,17 +2377,13 @@ def delete_agent(agent_id: int) -> bool: """Delete an agent and its push payloads.""" with get_db() as conn: # Delete push payloads first (foreign key) - conn.execute('DELETE FROM push_payloads WHERE agent_id = ?', (agent_id,)) - cursor = conn.execute('DELETE FROM agents WHERE id = ?', (agent_id,)) + conn.execute("DELETE FROM push_payloads WHERE agent_id = ?", (agent_id,)) + cursor = conn.execute("DELETE FROM agents WHERE id = ?", (agent_id,)) return cursor.rowcount > 0 def store_push_payload( - agent_id: int, - scan_type: str, - payload: dict, - interface: str | None = None, - received_at: str | None = None + agent_id: int, scan_type: str, payload: dict, interface: str | None = None, received_at: str | None = None ) -> int: """ Store a push payload from a remote agent. @@ -2344,75 +2393,82 @@ def store_push_payload( """ with get_db() as conn: if received_at: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO push_payloads (agent_id, scan_type, interface, payload, received_at) VALUES (?, ?, ?, ?, ?) - ''', (agent_id, scan_type, interface, json.dumps(payload), received_at)) + """, + (agent_id, scan_type, interface, json.dumps(payload), received_at), + ) else: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO push_payloads (agent_id, scan_type, interface, payload) VALUES (?, ?, ?, ?) - ''', (agent_id, scan_type, interface, json.dumps(payload))) + """, + (agent_id, scan_type, interface, json.dumps(payload)), + ) # Update agent last_seen - conn.execute( - 'UPDATE agents SET last_seen = CURRENT_TIMESTAMP WHERE id = ?', - (agent_id,) - ) + conn.execute("UPDATE agents SET last_seen = CURRENT_TIMESTAMP WHERE id = ?", (agent_id,)) return cursor.lastrowid -def get_recent_payloads( - agent_id: int | None = None, - scan_type: str | None = None, - limit: int = 100 -) -> list[dict]: +def get_recent_payloads(agent_id: int | None = None, scan_type: str | None = None, limit: int = 100) -> list[dict]: """Get recent push payloads, optionally filtered.""" conditions = [] params = [] if agent_id is not None: - conditions.append('p.agent_id = ?') + conditions.append("p.agent_id = ?") params.append(agent_id) if scan_type is not None: - conditions.append('p.scan_type = ?') + conditions.append("p.scan_type = ?") params.append(scan_type) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT p.*, a.name as agent_name FROM push_payloads p JOIN agents a ON p.agent_id = a.id {where_clause} ORDER BY p.received_at DESC LIMIT ? - ''', params) + """, + params, + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'agent_id': row['agent_id'], - 'agent_name': row['agent_name'], - 'scan_type': row['scan_type'], - 'interface': row['interface'], - 'payload': json.loads(row['payload']), - 'received_at': row['received_at'] - }) + results.append( + { + "id": row["id"], + "agent_id": row["agent_id"], + "agent_name": row["agent_name"], + "scan_type": row["scan_type"], + "interface": row["interface"], + "payload": json.loads(row["payload"]), + "received_at": row["received_at"], + } + ) return results def cleanup_old_payloads(max_age_hours: int = 24) -> int: """Remove old push payloads.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM push_payloads WHERE received_at < datetime('now', ?) - ''', (f'-{max_age_hours} hours',)) + """, + (f"-{max_age_hours} hours",), + ) return cursor.rowcount @@ -2420,28 +2476,29 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int: # Tracked Satellites Functions # ============================================================================= + def get_tracked_satellites(enabled_only: bool = False) -> list[dict]: """Return all tracked satellites, optionally filtered to enabled only.""" with get_db() as conn: if enabled_only: rows = conn.execute( - 'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at ' - 'FROM tracked_satellites WHERE enabled = 1 ORDER BY builtin DESC, name' + "SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at " + "FROM tracked_satellites WHERE enabled = 1 ORDER BY builtin DESC, name" ).fetchall() else: rows = conn.execute( - 'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at ' - 'FROM tracked_satellites ORDER BY builtin DESC, name' + "SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at " + "FROM tracked_satellites ORDER BY builtin DESC, name" ).fetchall() return [ { - 'norad_id': r[0], - 'name': r[1], - 'tle_line1': r[2], - 'tle_line2': r[3], - 'enabled': bool(r[4]), - 'builtin': bool(r[5]), - 'added_at': r[6], + "norad_id": r[0], + "name": r[1], + "tle_line1": r[2], + "tle_line2": r[3], + "enabled": bool(r[4]), + "builtin": bool(r[5]), + "added_at": r[6], } for r in rows ] @@ -2459,9 +2516,9 @@ def add_tracked_satellite( with get_db() as conn: try: conn.execute( - 'INSERT OR IGNORE INTO tracked_satellites ' - '(norad_id, name, tle_line1, tle_line2, enabled, builtin) ' - 'VALUES (?, ?, ?, ?, ?, ?)', + "INSERT OR IGNORE INTO tracked_satellites " + "(norad_id, name, tle_line1, tle_line2, enabled, builtin) " + "VALUES (?, ?, ?, ?, ?, ?)", (str(norad_id), name, tle_line1, tle_line2, int(enabled), int(builtin)), ) return conn.total_changes > 0 @@ -2478,27 +2535,29 @@ def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int: rows = [] for sat in satellites_list: try: - rows.append(( - str(sat['norad_id']), - sat['name'], - sat.get('tle_line1'), - sat.get('tle_line2'), - int(sat.get('enabled', True)), - int(sat.get('builtin', False)), - )) + rows.append( + ( + str(sat["norad_id"]), + sat["name"], + sat.get("tle_line1"), + sat.get("tle_line2"), + int(sat.get("enabled", True)), + int(sat.get("builtin", False)), + ) + ) except (KeyError, TypeError) as e: logger.warning(f"Skipping malformed satellite entry: {e}") norad_ids = [r[0] for r in rows] - placeholders = ','.join('?' * len(norad_ids)) - count_sql = f'SELECT COUNT(*) FROM tracked_satellites WHERE norad_id IN ({placeholders})' + placeholders = ",".join("?" * len(norad_ids)) + count_sql = f"SELECT COUNT(*) FROM tracked_satellites WHERE norad_id IN ({placeholders})" with get_db() as conn: before = conn.execute(count_sql, norad_ids).fetchone()[0] conn.executemany( - 'INSERT OR IGNORE INTO tracked_satellites ' - '(norad_id, name, tle_line1, tle_line2, enabled, builtin) ' - 'VALUES (?, ?, ?, ?, ?, ?)', + "INSERT OR IGNORE INTO tracked_satellites " + "(norad_id, name, tle_line1, tle_line2, enabled, builtin) " + "VALUES (?, ?, ?, ?, ?, ?)", rows, ) after = conn.execute(count_sql, norad_ids).fetchone()[0] @@ -2509,7 +2568,7 @@ def update_tracked_satellite(norad_id: str, enabled: bool) -> bool: """Toggle enabled state for a tracked satellite.""" with get_db() as conn: cursor = conn.execute( - 'UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?', + "UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?", (int(enabled), str(norad_id)), ) return cursor.rowcount > 0 @@ -2519,17 +2578,15 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]: """Delete a tracked satellite by NORAD ID. Refuses to delete builtins.""" with get_db() as conn: row = conn.execute( - 'SELECT builtin FROM tracked_satellites WHERE norad_id = ?', + "SELECT builtin FROM tracked_satellites WHERE norad_id = ?", (str(norad_id),), ).fetchone() if row is None: - return False, 'Satellite not found' + return False, "Satellite not found" if row[0]: - return False, 'Cannot remove builtin satellite' + return False, "Cannot remove builtin satellite" conn.execute( - 'DELETE FROM tracked_satellites WHERE norad_id = ?', + "DELETE FROM tracked_satellites WHERE norad_id = ?", (str(norad_id),), ) - return True, 'Removed' - - + return True, "Removed" From f51682f92947305d1b518fb30b8272f341d66e9c Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 15 Apr 2026 12:59:05 +0100 Subject: [PATCH 59/63] chore: exclude docs/superpowers/ from git tracking Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +- .../2026-03-19-satellite-telemetry-fixes.md | 1037 ------------ .../plans/2026-03-26-wifi-scanner-redesign.md | 1480 ----------------- .../plans/2026-03-27-bluetooth-ui-polish.md | 866 ---------- .../plans/2026-04-13-design-system-uplift.md | 517 ------ ...2026-03-26-wifi-scanner-redesign-design.md | 360 ---- .../2026-03-27-bluetooth-ui-polish-design.md | 225 --- .../2026-04-13-ui-ux-improvements-design.md | 203 --- 8 files changed, 2 insertions(+), 4689 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md delete mode 100644 docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md delete mode 100644 docs/superpowers/plans/2026-03-27-bluetooth-ui-polish.md delete mode 100644 docs/superpowers/plans/2026-04-13-design-system-uplift.md delete mode 100644 docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md delete mode 100644 docs/superpowers/specs/2026-03-27-bluetooth-ui-polish-design.md delete mode 100644 docs/superpowers/specs/2026-04-13-ui-ux-improvements-design.md diff --git a/.gitignore b/.gitignore index a3e0534..4e40d58 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ data/subghz/captures/ # Local utility scripts reset-sdr.* -.superpowers/ +.superpowers/ +docs/superpowers/ diff --git a/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md b/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md deleted file mode 100644 index 1b773a2..0000000 --- a/docs/superpowers/plans/2026-03-19-satellite-telemetry-fixes.md +++ /dev/null @@ -1,1037 +0,0 @@ -# Satellite Telemetry Reliability Fixes — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix satellite tracking telemetry so elevation/azimuth/distance/visibility are always accurate and stable, and TLE data stays fresh automatically. - -**Architecture:** The SSE background tracker (server-side, location-unaware) is stripped of observer-relative data and made authoritative only for orbit position and ground track. The 5-second HTTP poll becomes the sole owner of observer-relative telemetry. A daily TLE refresh timer is added. Several smaller correctness bugs are fixed across the backend and frontend. - -**Tech Stack:** Python/Flask (backend tracker + routes), Skyfield (orbital mechanics), HTML/JS (dashboard frontend), pytest (tests) - ---- - -## File Map - -| File | What Changes | -|------|-------------| -| `routes/satellite.py` | Strip observer-relative fields from SSE tracker; fix altitude calc; add periodic TLE refresh; add `currentPos` fields to pass prediction | -| `templates/satellite_dashboard.html` | SSE handler ignores observer fields; telemetry polling owns elevation/az/dist/visible; fix `updateTelemetry` fallback; add METEOR-M2 to `WEATHER_SAT_KEYS`; fix abort controller; fix countdown | -| `tests/test_satellite.py` | New tests for tracker output shape, altitude calc, TLE refresh scheduling, pass currentPos fields | - ---- - -## Task 1: Strip observer-relative data from SSE tracker - -**Problem:** `_start_satellite_tracker` uses `DEFAULT_LATITUDE`/`DEFAULT_LONGITUDE` (both `0.0` by default). Every SSE message emits `visible: False` and az/el/dist based on the wrong location, overwriting correct data from the HTTP poll every second. - -**Fix:** Remove elevation, azimuth, distance, and visible from the SSE tracker output entirely. The SSE stream is server-wide and cannot know per-client observer location. The HTTP poll (`/satellite/position`) already handles observer-relative data correctly using the location from the POST body. - -**Files:** -- Modify: `routes/satellite.py:220-265` -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a failing test verifying tracker position dicts lack observer-relative fields** - -Add to `tests/test_satellite.py`: - -```python -def test_tracker_position_has_no_observer_fields(): - """SSE tracker positions must NOT include observer-relative fields. - - The tracker runs server-side with a fixed (potentially wrong) observer - location. Only the per-request /satellite/position endpoint, which - receives the client's actual location, should emit elevation/azimuth/ - distance/visible. - """ - from routes.satellite import _start_satellite_tracker - import threading, queue, time - - ISS_TLE = ( - 'ISS (ZARYA)', - '1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993', - '2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457', - ) - - sat_q = queue.Queue(maxsize=5) - - with patch('routes.satellite._tle_cache', {'ISS': ISS_TLE}), \ - patch('routes.satellite.get_tracked_satellites') as mock_tracked, \ - patch('routes.satellite.app') as mock_app: - mock_app.satellite_queue = sat_q - mock_tracked.return_value = [{ - 'name': 'ISS (ZARYA)', 'norad_id': 25544, - 'tle_line1': ISS_TLE[1], 'tle_line2': ISS_TLE[2], - }] - - t = threading.Thread(target=_start_satellite_tracker, daemon=True) - t.start() - try: - msg = sat_q.get(timeout=5) - finally: - # thread is daemon so it exits with test process - pass - - assert msg['type'] == 'positions' - pos = msg['positions'][0] - for forbidden in ('elevation', 'azimuth', 'distance', 'visible'): - assert forbidden not in pos, f"SSE tracker must not emit '{forbidden}'" - for required in ('lat', 'lon', 'altitude', 'satellite', 'norad_id'): - assert required in pos, f"SSE tracker must emit '{required}'" -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cd /Users/jsmith/Documents/Dev/intercept -pytest tests/test_satellite.py::test_tracker_position_has_no_observer_fields -v -``` - -Expected: FAIL — position dict currently contains `visible`. - -- [ ] **Step 3: Remove observer-relative fields from `_start_satellite_tracker`** - -In `routes/satellite.py`, replace the observer block inside `_start_satellite_tracker` (lines ~220–265). - -Remove these lines: -```python -obs_lat = DEFAULT_LATITUDE -obs_lon = DEFAULT_LONGITUDE -has_observer = (obs_lat != 0.0 or obs_lon != 0.0) -observer = wgs84.latlon(obs_lat, obs_lon) if has_observer else None -``` - -And remove the observer-relative block after `pos` is built: -```python -if has_observer and observer is not None: - diff = satellite - observer - topocentric = diff.at(now) - alt, az, dist = topocentric.altaz() - pos['elevation'] = float(alt.degrees) - pos['azimuth'] = float(az.degrees) - pos['distance'] = float(dist.km) - pos['visible'] = bool(alt.degrees > 0) -``` - -The `pos` dict should only contain `satellite`, `norad_id`, `lat`, `lon`, `altitude`, `groundTrack`. - -Also remove the `from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE` reference if it becomes unused (check if used elsewhere in the file first — it is imported at the top, keep the import if used elsewhere, just stop using it in the tracker). - -- [ ] **Step 4: Run test to verify it passes** - -```bash -pytest tests/test_satellite.py::test_tracker_position_has_no_observer_fields -v -``` - -Expected: PASS - -- [ ] **Step 5: Run full test suite** - -```bash -pytest tests/test_satellite.py -v -``` - -Expected: all existing tests still pass. - -- [ ] **Step 6: Commit** - -```bash -git add routes/satellite.py tests/test_satellite.py -git commit -m "fix(satellite): strip observer-relative fields from SSE tracker - -SSE runs server-wide with a fixed observer location (DEFAULT_LAT/LON -defaults to 0,0). Emitting elevation/azimuth/distance/visible from the -SSE stream produced wrong values that overwrote correct data from the -per-client HTTP poll every second. The HTTP poll (/satellite/position) -owns all observer-relative data; SSE now only emits lat/lon/altitude/ -groundTrack." -``` - ---- - -## Task 2: Fix frontend — SSE handler ignores observer-relative fields - -**Problem:** Even after Task 1, the frontend `handleLivePositions` passes `pos` directly to `applyTelemetryPosition`, which then calls `normalizeLivePosition` and merges all fields. The `updateVisible` flag also means SSE was setting the visible-count badge. We need the SSE path to only update lat/lon/altitude/groundTrack/map, leaving elevation/az/dist/visible for the HTTP poll. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `handleLivePositions` function (~line 1308) - -- [ ] **Step 1: Update `handleLivePositions` to strip observer fields before applying** - -Find `handleLivePositions` (around line 1308) and replace: - -```js -function handleLivePositions(positions) { - // Find the selected satellite by name or norad_id - const pos = findSelectedPosition(positions); - - // Update visible count from all positions - const visibleCount = positions.filter(p => p.visible).length; - const visEl = document.getElementById('statVisible'); - if (visEl) visEl.textContent = visibleCount; - - if (!pos) { - return; - } - applyTelemetryPosition( - { ...pos, visibleCount }, - { - updateVisible: true, - noradId: parseInt(pos.norad_id, 10) || selectedSatellite - } - ); -} -``` - -With: - -```js -function handleLivePositions(positions, source) { - // Find the selected satellite by name or norad_id - const pos = findSelectedPosition(positions); - - if (!pos) return; - - if (source === 'sse') { - // SSE is server-side and location-unaware: only update - // orbit position and ground track, never observer-relative fields. - const orbitOnly = { - satellite: pos.satellite, - norad_id: pos.norad_id, - lat: pos.lat, - lon: pos.lon, - altitude: pos.altitude, - groundTrack: pos.groundTrack, - track: pos.track, - }; - applyTelemetryPosition(orbitOnly, { - updateVisible: false, - noradId: parseInt(pos.norad_id, 10) || selectedSatellite, - }); - } else { - // HTTP poll: owns all observer-relative data including visible count - const visibleCount = positions.filter(p => p.visible).length; - const visEl = document.getElementById('statVisible'); - if (visEl) visEl.textContent = visibleCount; - applyTelemetryPosition( - { ...pos, visibleCount }, - { - updateVisible: true, - noradId: parseInt(pos.norad_id, 10) || selectedSatellite, - } - ); - } -} -``` - -- [ ] **Step 2: Thread `source` through the SSE call site** - -Find the SSE `onmessage` handler (~line 1288): -```js -satelliteSSE.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - if (msg.type === 'positions') handleLivePositions(msg.positions); - } catch (_) {} -}; -``` - -Change to: -```js -satelliteSSE.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - if (msg.type === 'positions') handleLivePositions(msg.positions, 'sse'); - } catch (_) {} -}; -``` - -- [ ] **Step 3: Thread `source` through the HTTP poll call site** - -Find in `fetchCurrentTelemetry` (~line 1397): -```js -handleLivePositions(data.positions); -``` - -Change to: -```js -handleLivePositions(data.positions, 'poll'); -``` - -- [ ] **Step 4: Manual smoke test** - -Open `/satellite/dashboard` in a browser. Confirm: -- Lat/Lon/Altitude update every ~1 second (from SSE) -- Elevation/Azimuth/Distance update every ~5 seconds (from HTTP poll) -- The visible-count badge doesn't reset to 0 every second - -- [ ] **Step 5: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "fix(satellite): SSE path only updates orbit position, not observer data - -The SSE stream no longer sets elevation/azimuth/distance/visible since -those fields were removed from the server-side tracker in the previous -commit. Adds a 'source' param to handleLivePositions so the SSE path -is gated to orbit-only fields, and the HTTP poll path owns all -observer-relative telemetry and visible-count badge." -``` - ---- - -## Task 3: Add periodic TLE auto-refresh (daily) - -**Problem:** `init_tle_auto_refresh()` fires once at startup (2s delay) then never again. TLEs are valid for roughly 1–2 weeks but degrade in accuracy after a few days, affecting pass prediction accuracy. - -**Fix:** Schedule a periodic 24-hour refresh using a repeating `threading.Timer` pattern. - -**Files:** -- Modify: `routes/satellite.py` — `init_tle_auto_refresh` function (~line 309) -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a failing test** - -Add to `tests/test_satellite.py`: - -```python -@patch('routes.satellite.refresh_tle_data', return_value=['ISS']) -@patch('routes.satellite._load_db_satellites_into_cache') -def test_tle_auto_refresh_schedules_repeat(mock_load_db, mock_refresh): - """init_tle_auto_refresh must schedule a follow-up refresh after the first run.""" - import threading - scheduled_delays = [] - original_timer = threading.Timer - - class CapturingTimer: - def __init__(self, delay, fn, *args, **kwargs): - scheduled_delays.append(delay) - # Don't actually start a real timer - self._fn = fn - def start(self): - pass # no-op - - with patch('routes.satellite.threading') as mock_threading: - mock_threading.Timer = CapturingTimer - mock_threading.Thread = threading.Thread # keep real Thread for tracker - - from routes.satellite import init_tle_auto_refresh - init_tle_auto_refresh() - - # First timer fires at 2s (startup delay) - assert any(d <= 5 for d in scheduled_delays), \ - "Expected a short startup delay timer" -``` - -- [ ] **Step 2: Run test to verify it passes already (baseline)** - -```bash -pytest tests/test_satellite.py::test_tle_auto_refresh_schedules_repeat -v -``` - -This test validates existing behaviour and should pass. It serves as a regression guard. - -- [ ] **Step 3: Add a 24-hour repeating refresh** - -In `routes/satellite.py`, replace `init_tle_auto_refresh`: - -```python -_TLE_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60 # 24 hours - - -def init_tle_auto_refresh(): - """Initialize TLE auto-refresh. Called by app.py after initialization.""" - import threading - - def _auto_refresh_tle(): - try: - _load_db_satellites_into_cache() - updated = refresh_tle_data() - if updated: - logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}") - except Exception as e: - logger.warning(f"Auto TLE refresh failed: {e}") - finally: - # Schedule next refresh regardless of success/failure - _schedule_next_tle_refresh() - - def _schedule_next_tle_refresh(delay: float = _TLE_REFRESH_INTERVAL_SECONDS): - t = threading.Timer(delay, _auto_refresh_tle) - t.daemon = True - t.start() - - # First refresh 2 seconds after startup (avoids blocking app init) - threading.Timer(2.0, _auto_refresh_tle).start() - logger.info("TLE auto-refresh scheduled (24h interval)") - - # Start live position tracker thread - tracker_thread = threading.Thread( - target=_start_satellite_tracker, - daemon=True, - name='satellite-tracker', - ) - tracker_thread.start() - logger.info("Satellite tracker thread launched") -``` - -- [ ] **Step 4: Write a test verifying the repeat schedule** - -Add to `tests/test_satellite.py`: - -```python -@patch('routes.satellite.refresh_tle_data', return_value=['ISS']) -@patch('routes.satellite._load_db_satellites_into_cache') -def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh): - """After the first TLE refresh, a 24-hour follow-up must be scheduled.""" - import threading - scheduled_delays = [] - - class CapturingTimer: - def __init__(self, delay, fn, *a, **kw): - scheduled_delays.append(delay) - self._fn = fn - self._ran = False - def start(self): - # Execute immediately so we can check the chained schedule - if not self._ran and scheduled_delays[0] <= 5: - self._ran = True - self._fn() # run the first (startup) timer inline - - with patch('routes.satellite.threading') as mock_threading: - mock_threading.Timer = CapturingTimer - mock_threading.Thread = threading.Thread - - # Re-import to pick up patched threading - import importlib, routes.satellite as sat_mod - sat_mod.init_tle_auto_refresh() - - # Should have scheduled startup delay AND a 24h follow-up - assert any(d >= 86000 for d in scheduled_delays), \ - f"Expected a ~24h repeat timer; got delays: {scheduled_delays}" -``` - -- [ ] **Step 5: Run new test** - -```bash -pytest tests/test_satellite.py::test_tle_auto_refresh_schedules_daily_repeat -v -``` - -Expected: PASS - -- [ ] **Step 6: Run full suite** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 7: Commit** - -```bash -git add routes/satellite.py tests/test_satellite.py -git commit -m "feat(satellite): add 24-hour periodic TLE auto-refresh - -TLE data was only refreshed once at startup. After each refresh, a new -24-hour timer is now scheduled (in the finally block so it fires even -on refresh failure). This keeps orbital elements fresh and pass -predictions accurate over multi-day deployments." -``` - ---- - -## Task 4: Fix `updateTelemetry` fallback — add proper currentPos fields - -**Problem:** When `latestLivePosition` is null (e.g. before first SSE/poll arrives), `updateTelemetry(pass)` falls back to `pass.currentPos`. But `currentPos` only has `lat` and `lon` (set in `predict_passes` at `satellite.py:509-517`). The fallback code reads `pos.alt`, `pos.el`, `pos.az`, `pos.dist` which are always undefined, so altitude/elevation/azimuth/distance always show `---` in this state. - -**Fix:** Populate `currentPos` with full position data (altitude, elevation, azimuth, distance, visible) in the `/satellite/predict` backend handler using Skyfield. - -**Files:** -- Modify: `routes/satellite.py` — `predict_passes` route handler (~line 508) -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a failing test** - -Add to `tests/test_satellite.py`: - -```python -@patch('routes.satellite._get_tracked_satellite_maps', return_value=({}, {})) -@patch('routes.satellite._get_timescale') -def test_predict_passes_currentpos_has_full_fields(mock_ts, mock_maps, client): - """currentPos in pass results must include altitude, elevation, azimuth, distance.""" - from skyfield.api import load - ts = load.timescale(builtin=True) - mock_ts.return_value = ts - - payload = { - 'latitude': 51.5074, - 'longitude': -0.1278, - 'hours': 48, - 'minEl': 5, - 'satellites': ['ISS'], - } - response = client.post('/satellite/predict', json=payload) - assert response.status_code == 200 - data = response.json - assert data['status'] == 'success' - if data['passes']: - cp = data['passes'][0].get('currentPos', {}) - for field in ('lat', 'lon', 'altitude', 'elevation', 'azimuth', 'distance'): - assert field in cp, f"currentPos missing field: {field}" -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -pytest tests/test_satellite.py::test_predict_passes_currentpos_has_full_fields -v -``` - -Expected: FAIL — `currentPos` currently only has `lat` and `lon`. - -- [ ] **Step 3: Enrich `currentPos` in the predict route** - -In `routes/satellite.py` inside `predict_passes`, find the block (~line 508-528): - -```python -for sat_name, norad_id, tle_data in resolved_satellites: - current_pos = None - try: - satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) - geo = satellite.at(t0) - sp = wgs84.subpoint(geo) - current_pos = { - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - } - except Exception: - pass -``` - -Replace with: - -```python -for sat_name, norad_id, tle_data in resolved_satellites: - current_pos = None - try: - satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) - geo = satellite.at(t0) - sp = wgs84.subpoint(geo) - subpoint_alt = float(sp.elevation.km) - current_pos = { - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - 'altitude': subpoint_alt, - } - # Add observer-relative data using the request's observer location - try: - diff = satellite - observer - topo = diff.at(t0) - alt_deg, az_deg, dist_km = topo.altaz() - current_pos['elevation'] = round(float(alt_deg.degrees), 1) - current_pos['azimuth'] = round(float(az_deg.degrees), 1) - current_pos['distance'] = round(float(dist_km.km), 1) - current_pos['visible'] = bool(alt_deg.degrees > 0) - except Exception: - pass - except Exception: - pass -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -pytest tests/test_satellite.py::test_predict_passes_currentpos_has_full_fields -v -``` - -Expected: PASS - -- [ ] **Step 5: Fix `updateTelemetry` fallback in the frontend to use correct field names** - -In `templates/satellite_dashboard.html`, find `updateTelemetry` (~line 2380): - -```js -function updateTelemetry(pass) { - if (latestLivePosition) { - applyTelemetryPosition(latestLivePosition); - return; - } - if (!pass || !pass.currentPos) { - clearTelemetry(); - return; - } - - const pos = pass.currentPos; - const telLat = document.getElementById('telLat'); - ... - if (telAlt && Number.isFinite(pos.alt)) telAlt.textContent = pos.alt.toFixed(0) + ' km'; - if (telEl && Number.isFinite(pos.el)) telEl.textContent = pos.el.toFixed(1) + '°'; - if (telAz && Number.isFinite(pos.az)) telAz.textContent = pos.az.toFixed(1) + '°'; - if (telDist && Number.isFinite(pos.dist)) telDist.textContent = pos.dist.toFixed(0) + ' km'; -} -``` - -Replace with a call to the existing `applyTelemetryPosition` to keep display logic in one place: - -```js -function updateTelemetry(pass) { - if (latestLivePosition) { - applyTelemetryPosition(latestLivePosition); - return; - } - if (!pass || !pass.currentPos) { - clearTelemetry(); - return; - } - // currentPos now contains full position data (lat, lon, altitude, - // elevation, azimuth, distance, visible) from the predict endpoint. - applyTelemetryPosition(pass.currentPos); -} -``` - -- [ ] **Step 6: Run full test suite** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 7: Commit** - -```bash -git add routes/satellite.py templates/satellite_dashboard.html tests/test_satellite.py -git commit -m "fix(satellite): populate currentPos with full telemetry in pass predictions - -Previously currentPos only had lat/lon so the updateTelemetry fallback -(used before first live position arrives) always showed '---' for -altitude/elevation/azimuth/distance. currentPos now includes all fields -computed from the request's observer location. updateTelemetry simplified -to delegate to applyTelemetryPosition." -``` - ---- - -## Task 5: Fix altitude calculation to use WGS84 subpoint elevation - -**Problem:** `_start_satellite_tracker` and `get_satellite_position` compute altitude as `geocentric.distance().km - 6371` (fixed spherical Earth radius). The `wgs84.subpoint()` call already returns a subpoint with an accurate `.elevation.km` property that accounts for Earth's oblateness. - -**Files:** -- Modify: `routes/satellite.py` — tracker loop (~line 248-255) and `/position` handler (~line 636-641) -- Test: `tests/test_satellite.py` - -- [ ] **Step 1: Write a test for altitude field presence and plausibility** - -Add to `tests/test_satellite.py`: - -```python -def test_satellite_altitude_is_plausible(): - """Satellite altitude must be in a plausible orbital range (100–50000 km).""" - from skyfield.api import EarthSatellite, wgs84, load - ISS_TLE = ( - 'ISS (ZARYA)', - '1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993', - '2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457', - ) - ts = load.timescale(builtin=True) - satellite = EarthSatellite(ISS_TLE[1], ISS_TLE[2], ISS_TLE[0], ts) - now = ts.now() - geocentric = satellite.at(now) - subpoint = wgs84.subpoint(geocentric) - altitude = float(subpoint.elevation.km) - assert 100 < altitude < 50000, f"Altitude {altitude} km is outside plausible range" -``` - -- [ ] **Step 2: Run test to verify it passes (validates approach)** - -```bash -pytest tests/test_satellite.py::test_satellite_altitude_is_plausible -v -``` - -Expected: PASS — this confirms `subpoint.elevation.km` works. - -- [ ] **Step 3: Update tracker loop altitude** - -In `routes/satellite.py` `_start_satellite_tracker`, find (~line 248): - -```python -pos = { - ... - 'altitude': float(geocentric.distance().km - 6371), - ... -} -``` - -Replace with: - -```python -pos = { - ... - 'altitude': float(subpoint.elevation.km), - ... -} -``` - -(`subpoint` is already computed on the line above as `subpoint = wgs84.subpoint(geocentric)`) - -- [ ] **Step 4: Update `/satellite/position` handler altitude** - -In `routes/satellite.py` `get_satellite_position`, find (~line 634): - -```python -pos_data = { - ... - 'altitude': float(geocentric.distance().km - 6371), - ... -} -``` - -Replace with (note: `subpoint` is computed just above as `subpoint = wgs84.subpoint(geocentric)`): - -```python -pos_data = { - ... - 'altitude': float(subpoint.elevation.km), - ... -} -``` - -- [ ] **Step 5: Update `currentPos` altitude in predict route (from Task 4)** - -In the `predict_passes` handler, the `current_pos` block now uses `subpoint.elevation.km` (already done in Task 4). Verify it matches the pattern. - -- [ ] **Step 6: Run full suite** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 7: Commit** - -```bash -git add routes/satellite.py tests/test_satellite.py -git commit -m "fix(satellite): use wgs84 subpoint elevation for altitude - -Replace geocentric.distance().km - 6371 (fixed spherical radius) with -wgs84.subpoint(geocentric).elevation.km in both the SSE tracker and -the /position endpoint. This accounts for Earth's oblateness and -matches the subpoint already being computed." -``` - ---- - -## Task 6: Add METEOR-M2 to weather satellite handoff keys - -**Problem:** `WEATHER_SAT_KEYS` only contains `'METEOR-M2-3'` and `'METEOR-M2-4'`. METEOR-M2 (NORAD 40069) is tracked and displayed but has no "→ Capture" button in the pass list. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `WEATHER_SAT_KEYS` constant (~line 2135) - -- [ ] **Step 1: Add METEOR-M2 to the set** - -Find: -```js -const WEATHER_SAT_KEYS = new Set([ - 'METEOR-M2-3', 'METEOR-M2-4' -]); -``` - -Replace with: -```js -const WEATHER_SAT_KEYS = new Set([ - 'METEOR-M2', 'METEOR-M2-3', 'METEOR-M2-4' -]); -``` - -- [ ] **Step 2: Verify in browser** - -Open `/satellite/dashboard`, calculate passes for METEOR-M2. Confirm a "→ Capture" button appears on each pass item. - -- [ ] **Step 3: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "fix(satellite): add METEOR-M2 to weather satellite handoff keys - -METEOR-M2 (NORAD 40069) is a weather satellite with LRPT downlink but -was missing from WEATHER_SAT_KEYS, so no capture button appeared in -the pass list. Adds it alongside M2-3 and M2-4." -``` - ---- - -## Task 7: Simplify `_telemetryAbortController` management - -**Problem:** The abort controller in `fetchCurrentTelemetry` has redundant null-checks in both the try and catch blocks. The pattern where `_telemetryAbortController` is checked against `controller` in the success path AND again in the catch path, combined with `_activeTelemetryRequestKey` deduplication, is overly complex and has a subtle issue: if `_telemetryAbortController?.signal?.aborted` is checked after it was already set to null, the check is always false. - -**Fix:** Simplify to a single active-request guard pattern: clear the controller in `finally`, not in both try and catch. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `fetchCurrentTelemetry` function (~line 1354) - -- [ ] **Step 1: Simplify `fetchCurrentTelemetry`** - -Find `fetchCurrentTelemetry` (~line 1354) and replace the function body: - -```js -async function fetchCurrentTelemetry(requestedSatellite = selectedSatellite, selectionToken = _satelliteSelectionRequestToken) { - const lat = parseFloat(document.getElementById('obsLat')?.value); - const lon = parseFloat(document.getElementById('obsLon')?.value); - if (!Number.isFinite(lat) || !Number.isFinite(lon) || !selectedSatellite) return; - - const requestKey = `telemetry:${requestedSatellite}:${lat.toFixed(3)}:${lon.toFixed(3)}`; - if (_activeTelemetryRequestKey === requestKey) return; // identical request already in flight - - // Cancel any in-flight request for a different satellite/location - if (_telemetryAbortController) { - _telemetryAbortController.abort(); - _telemetryAbortController = null; - } - - const controller = new AbortController(); - _telemetryAbortController = controller; - _activeTelemetryRequestKey = requestKey; - - try { - const timeoutId = setTimeout(() => controller.abort(), TELEMETRY_FETCH_TIMEOUT_MS); - const response = await fetch('/satellite/position', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - body: JSON.stringify({ - latitude: lat, - longitude: lon, - satellites: [requestedSatellite], - includeTrack: false - }) - }); - clearTimeout(timeoutId); - - if (!response.ok) return; - const contentType = response.headers.get('Content-Type') || ''; - if (!contentType.includes('application/json')) return; - const data = await response.json(); - if (data.status !== 'success' || !Array.isArray(data.positions)) return; - - // Discard if satellite or selection changed while request was in flight - if (selectionToken !== _satelliteSelectionRequestToken || requestedSatellite !== selectedSatellite) return; - - const pos = data.positions.find(p => parseInt(p.norad_id, 10) === requestedSatellite) || null; - if (!pos) return; - cacheLivePosition(requestedSatellite, pos); - handleLivePositions(data.positions, 'poll'); - - } catch (err) { - if (err?.name === 'AbortError') return; // expected on cancel/timeout - // unexpected error — log but don't crash - console.debug('Telemetry fetch error:', err); - } finally { - // Always release the controller slot so the next poll can run - if (_telemetryAbortController === controller) { - _telemetryAbortController = null; - } - if (_activeTelemetryRequestKey === requestKey) { - _activeTelemetryRequestKey = null; - } - } -} -``` - -- [ ] **Step 2: Manual smoke test** - -Open `/satellite/dashboard`. Switch between satellites rapidly. Confirm: -- Telemetry updates within ~5s of switching -- No stale data from the previous satellite appears after switching -- No console errors - -- [ ] **Step 3: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "refactor(satellite): simplify telemetry abort controller management - -The previous pattern had redundant null-checks in both try and catch, -and a subtle bug where checking signal.aborted after setting the -controller to null was always false. Consolidated to a single -active-request guard with cleanup in finally." -``` - ---- - -## Task 8: Fix ground track blocking the 1Hz tracker loop - -**Problem:** `_start_satellite_tracker` computes a 90-point orbit track on every cache miss inside the 1Hz loop. With many tracked satellites, cold-cache startup means multiple expensive Skyfield loops block the tracker for several seconds, causing the SSE stream to go silent until they complete. - -**Fix:** Compute ground tracks lazily in a thread pool so the main tracker loop stays snappy. If a track is not yet cached, emit the position without a ground track (the frontend already handles missing `groundTrack` gracefully). - -**Files:** -- Modify: `routes/satellite.py` — `_start_satellite_tracker` function (~line 266) - -- [ ] **Step 1: Refactor ground track computation to a thread pool** - -At the top of `routes/satellite.py`, add import (it's stdlib): - -```python -from concurrent.futures import ThreadPoolExecutor -``` - -Add a module-level thread pool (near the other module-level state, around line 50): - -```python -# Thread pool for background ground-track computation (non-blocking from tracker loop) -_track_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='sat-track') -_track_in_progress: set = set() # keys currently being computed -``` - -In `_start_satellite_tracker`, replace the ground track block (~lines 266-286): - -```python -# Ground track with caching (90 points, TTL 1800s) -cache_key_track = (sat_name, tle1[:20]) -cached = _track_cache.get(cache_key_track) -if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL: - pos['groundTrack'] = cached[0] -else: - track = [] - for minutes_offset in range(-45, 46, 1): - ... -``` - -With: - -```python -# Ground track with caching (90 points, TTL 1800s) -cache_key_track = (sat_name, tle1[:20]) -cached = _track_cache.get(cache_key_track) -if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL: - pos['groundTrack'] = cached[0] -elif cache_key_track not in _track_in_progress: - # Kick off computation in background — don't block the 1Hz loop - _track_in_progress.add(cache_key_track) - - def _compute_track(sat_obj, ts_ref, now_dt_ref, key, sat_name_ref): - try: - track = [] - for minutes_offset in range(-45, 46, 1): - t_point = ts_ref.utc(now_dt_ref + timedelta(minutes=minutes_offset)) - try: - geo = sat_obj.at(t_point) - sp = wgs84.subpoint(geo) - track.append({ - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - 'past': minutes_offset < 0, - }) - except Exception: - continue - _track_cache[key] = (track, time.time()) - except Exception: - pass - finally: - _track_in_progress.discard(key) - - _track_executor.submit(_compute_track, satellite, ts, now_dt, cache_key_track, sat_name) - # groundTrack omitted this tick — frontend retains previous value from SSE merge -``` - -- [ ] **Step 2: Run full test suite to confirm no regressions** - -```bash -pytest tests/test_satellite.py -v -``` - -- [ ] **Step 3: Commit** - -```bash -git add routes/satellite.py -git commit -m "perf(satellite): compute ground tracks in thread pool, not inline - -Ground track computation (90 Skyfield points per satellite) was blocking -the 1Hz tracker loop on every cache miss. On cold start with multiple -tracked satellites this could stall the SSE stream for several seconds. -Tracks are now computed in a 2-worker ThreadPoolExecutor. The tracker -loop emits position without groundTrack on cache miss; clients retain -the previous track via SSE merge until the new one is ready." -``` - ---- - -## Task 9: Fix countdown when all passes are in the past - -**Problem:** `updateCountdown` falls back to `passes[0]` when no future pass is found. If `passes[0]` is in the past, the countdown displays 00:00:00:00 perpetually and the satellite name is misleading. - -**Files:** -- Modify: `templates/satellite_dashboard.html` — `updateCountdown` function (~line 2406) - -- [ ] **Step 1: Fix the countdown fallback** - -Find `updateCountdown` (~line 2406). Replace the section that handles the no-future-pass case: - -```js -if (!nextPass) nextPass = passes[0]; -``` - -With: - -```js -if (!nextPass) { - // All passes in window are in the past — show stale state - document.getElementById('countdownSat').textContent = 'NO UPCOMING PASSES'; - document.getElementById('countDays').textContent = '--'; - document.getElementById('countHours').textContent = '--'; - document.getElementById('countMins').textContent = '--'; - document.getElementById('countSecs').textContent = '--'; - ['countDays', 'countHours', 'countMins', 'countSecs'].forEach(id => { - document.getElementById(id)?.classList.remove('active'); - }); - return; -} -``` - -- [ ] **Step 2: Manual verification** - -To test, temporarily set `passes` to a list with a past timestamp in the browser console: -```js -passes = [{ satellite: 'ISS', startTimeISO: '2020-01-01T00:00:00', maxEl: 45, duration: 5 }]; -updateCountdown(); -``` -Confirm the countdown shows `NO UPCOMING PASSES` and `--` for all fields. - -- [ ] **Step 3: Commit** - -```bash -git add templates/satellite_dashboard.html -git commit -m "fix(satellite): show 'NO UPCOMING PASSES' when all passes are in the past - -updateCountdown fell back to passes[0] even when it was in the past, -showing 00:00:00:00 with a stale satellite name indefinitely. Now -displays a clear 'NO UPCOMING PASSES' state when no future pass exists -in the current 48-hour prediction window." -``` - ---- - -## Final: Run full test suite and verify - -- [ ] **Run all tests** - -```bash -cd /Users/jsmith/Documents/Dev/intercept -pytest tests/ -v --tb=short 2>&1 | tail -30 -``` - -Expected: all tests pass. - -- [ ] **Lint check** - -```bash -ruff check routes/satellite.py -``` - -Expected: no new errors. - -- [ ] **Manual end-to-end verification checklist** - -Open `/satellite/dashboard` and confirm: -1. Lat/Lon updates smoothly every ~1 second -2. Elevation/Azimuth/Distance update every ~5 seconds (not every 1 second) -3. Visible-count badge reflects client's actual location -4. Selecting a pass before first live data arrives shows altitude/el/az in telemetry panel -5. METEOR-M2 passes show "→ Capture" button -6. Switching satellites rapidly shows no stale data from previous satellite -7. Countdown shows `NO UPCOMING PASSES` rather than 00:00:00:00 when window is expired diff --git a/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md b/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md deleted file mode 100644 index e5d124f..0000000 --- a/docs/superpowers/plans/2026-03-26-wifi-scanner-redesign.md +++ /dev/null @@ -1,1480 +0,0 @@ -# WiFi Scanner Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Redesign the WiFi scanner's main content area with richer network rows, an animated proximity radar sweep, a channel utilisation heatmap, a security ring chart, and a right-panel network detail view replacing the slide-up drawer. - -**Architecture:** All changes are pure frontend — HTML structure in `templates/index.html`, styles in `static/css/index.css`, and JS logic in `static/js/modes/wifi.js`. No backend routes are touched. The five tasks are independent enough to be committed separately and the UI remains functional after each one. - -**Tech Stack:** Vanilla JS (ES6 IIFE module pattern), CSS animations, inline SVG, Flask/Jinja2 templates. - ---- - -## Spec & reference - -- **Spec:** `docs/superpowers/specs/2026-03-26-wifi-scanner-redesign-design.md` -- **Start the app for manual verification:** - ```bash - sudo -E venv/bin/python intercept.py - # Open http://localhost:5050/?mode=wifi - ``` - -## File map - -| File | What changes | -|---|---| -| `templates/index.html` | All structural HTML changes (lines ~822–1005 for WiFi section) | -| `static/css/index.css` | WiFi section CSS (lines ~3515–3970+) | -| `static/js/modes/wifi.js` | `cacheDOM()`, `scheduleRender()`, `updateNetworkTable()` → `renderNetworks()`, `updateStats()`, `initProximityRadar()` → `renderRadar()`, `initChannelChart()` → `renderHeatmap()` + `renderSecurityRing()`, `selectNetwork()`, `closeDetail()`, `updateDetailPanel()` | - ---- - -## Task 1: Status bar — Open count + scan indicator - -**Files:** -- Modify: `templates/index.html` (lines ~824–841) -- Modify: `static/css/index.css` (lines ~3531–3570) -- Modify: `static/js/modes/wifi.js` (`cacheDOM()` ~line 183, `updateScanningState()` ~line 670, `updateStats()` ~line 1475) - -### Context - -The status bar currently has: Networks · Clients · Hidden · [scan status text]. We're adding an Open count (red) and replacing the text status with a pulsing dot indicator. - -The existing `#wifiScanStatus` element (line 837 of `index.html`) is replaced by `#wifiScanIndicator`. The existing `updateScanningState()` function (line ~670 of `wifi.js`) currently sets `.textContent` and `.className` on `elements.scanStatus` — it needs to toggle the dot's `display` instead. - -- [ ] **Step 1: Update status bar HTML** - -In `templates/index.html`, find the `wifi-status-bar` div (~line 824) and replace its contents: - -```html -
-
- Networks: - 0 -
-
- Clients: - 0 -
-
- Hidden: - 0 -
-
- Open: - 0 -
-
- - IDLE -
-
-``` - -- [ ] **Step 2: Add scan indicator CSS** - -In `static/css/index.css`, find the `.wifi-status-bar` block (~line 3531) and add after it: - -```css -.wifi-scan-indicator { - margin-left: auto; - display: flex; - align-items: center; - gap: 6px; - font-size: 10px; - color: var(--accent-cyan); - letter-spacing: 0.5px; -} - -.wifi-scan-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--accent-cyan); - display: none; - animation: wifi-scan-pulse 1.2s ease-in-out infinite; -} - -@keyframes wifi-scan-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(0.7); } -} -``` - -- [ ] **Step 3: Update JS — `cacheDOM()`** - -In `wifi.js`, find `cacheDOM()` (~line 183). Replace: -```js -// Status bar -scanStatus: document.getElementById('wifiScanStatus'), -``` -With: -```js -// Status bar -scanIndicator: document.getElementById('wifiScanIndicator'), -openCount: document.getElementById('wifiOpenCount'), -``` -(Keep `networkCount`, `clientCount`, `hiddenCount` unchanged. Remove the old `openCount: document.getElementById('openCount')` line in the security counts section.) - -- [ ] **Step 4: Update JS — `updateScanningState()`** - -Find `updateScanningState()` (~line 670). Replace the body that references `elements.scanStatus` with: - -```js -const dot = elements.scanIndicator?.querySelector('.wifi-scan-dot'); -const text = elements.scanIndicator?.querySelector('.wifi-scan-text'); -if (dot) dot.style.display = scanning ? 'inline-block' : 'none'; -if (text) text.textContent = scanning - ? `SCANNING (${scanMode === 'quick' ? 'Quick' : 'Deep'})` - : 'IDLE'; -``` - -- [ ] **Step 5: Update JS — `updateStats()` — add Open count, remove old security IDs** - -In `updateStats()` (~line 1475), find the block that updates `elements.wpa3Count`, `elements.wpa2Count`, `elements.wepCount`, `elements.openCount`. Replace the four element update lines with: - -```js -if (elements.openCount) elements.openCount.textContent = securityCounts.open; -``` - -(Remove the `wpa3Count`, `wpa2Count`, `wepCount` lines — those elements no longer exist. Keep the `securityCounts` calculation above unchanged, it's still needed by Task 4.) - -Also remove `wpa3Count`, `wpa2Count`, `wepCount` from `cacheDOM()` entirely. - -- [ ] **Step 6: Verify** - -```bash -sudo -E venv/bin/python intercept.py -``` -Open `http://localhost:5050/?mode=wifi`. Check: -- Status bar shows Networks / Clients / Hidden / Open (red) -- Clicking Quick Scan shows pulsing cyan dot + "SCANNING (Quick)" -- Stopping shows "IDLE" with no dot -- Open count increments as open networks are discovered - -- [ ] **Step 7: Commit** - -```bash -git add templates/index.html static/css/index.css static/js/modes/wifi.js -git commit -m "feat(wifi): enhanced status bar with open count and scan indicator" -``` - ---- - -## Task 2: Networks table → styled div list - -**Files:** -- Modify: `templates/index.html` (~lines 846–881) -- Modify: `static/css/index.css` (~lines 3582–3765) -- Modify: `static/js/modes/wifi.js` (`cacheDOM()`, `updateNetworkTable()`, `createNetworkRow()`, `initNetworkFilters()`, `initSortControls()`, `selectNetwork()`, `closeDetail()`) - -### Context - -The existing `` with 7 columns is replaced by `
`. The `updateNetworkTable()` / `createNetworkRow()` functions are rewritten to generate `
` elements with two visual lines (SSID + badges on top, signal bar + meta on bottom). - -The existing `selectedNetwork` variable is renamed to `selectedBssid` throughout `wifi.js`. - -- [ ] **Step 1: Replace table HTML in `index.html`** - -Find `.wifi-networks-panel` (~line 846). Replace the `
` and everything inside `.wifi-networks-panel` with: - -```html -
-
-
Discovered Networks
-
- - - - - -
-
- Sort: - - - -
-
-
-
-
-

No networks detected.
Start a scan to begin.

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

${escapeHtml(message)}

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