23 Commits
Author SHA1 Message Date
iamdoubz fb63e44a3c Update README with new functionality and details 2026-06-11 09:22:43 -05:00
iamdoubz bd3daa548f Update access points page with new screenshot 2026-06-11 09:22:13 -05:00
iamdoubz 29deced348 Merge pull request '[Feature] Add Wifi 6e and 7' (#18) from feature_add_6e7 into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/18
2026-06-10 20:42:36 -05:00
iamdoubz b6e0d0371a Bump new version 2026-06-10 20:40:04 -05:00
iamdoubz 92f0ab2c38 Add decision step for channel widths and the process used to validate them 2026-06-10 20:38:24 -05:00
iamdoubz f51e3e8785 Verify channel widths using linux kernel source 2026-06-10 20:35:24 -05:00
iamdoubz bc2c3168bc Better security, width, vendor, and type detection 2026-06-10 18:38:30 -05:00
iamdoubz 44b9e1be09 Fix issue where wlanapi never shows security type 2026-06-10 16:36:03 -05:00
iamdoubz d8d05c2318 Add 6g, Wifi 7, and Wifi 6e to scanner 2026-06-10 16:16:11 -05:00
iamdoubz 6e9ca3acb1 I have to add this file now 2026-06-10 16:05:03 -05:00
iamdoubz 009ea8c816 Bump latest release version 2026-05-04 12:02:03 -05:00
iamdoubz 79f864c4aa Add additional screenshot 2026-05-04 12:01:45 -05:00
iamdoubz b25cc5f29d Add single bssid w/ap image 2026-05-04 12:01:12 -05:00
iamdoubz 0001a8a702 Merge pull request 'Remove dup ap marker and actually render the legend' (#17) from legend_fix2 into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/17
2026-05-04 11:31:22 -05:00
iamdoubz 621978d1d6 Remove dup ap marker and actually render the legend 2026-05-04 11:27:32 -05:00
iamdoubz bfdf025c37 Add a few more images 2026-05-04 11:21:06 -05:00
iamdoubz 329e81bf86 Bump latest release version 2026-05-04 09:14:06 -05:00
iamdoubz 19b6447eaf Fix legend alpha display issue 2026-05-04 09:13:30 -05:00
iamdoubz 7befa142a6 Change display text and relative logos in GUI 2026-05-04 08:38:43 -05:00
iamdoubz 97c2c4d3bb Merge pull request 'Use repo logos instead of generic ones' (#16) from bundle_logo_exe into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/16
2026-05-04 08:27:31 -05:00
iamdoubz 1086a98e7b Use repo logos instead of generic ones 2026-05-04 08:23:43 -05:00
iamdoubz a76a7f221d Merge pull request 'Change program render title and logos' (#15) from program_logo_change into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/15
2026-05-04 04:32:03 -05:00
iamdoubz 3291fa0867 Change program render title and logos 2026-05-04 04:31:05 -05:00
9 changed files with 1245 additions and 235 deletions
+647
View File
@@ -0,0 +1,647 @@
# CLAUDE.md — pyFi Project Guide
This file is the authoritative context document for Claude Code sessions on the
pyFi project. Read this file completely before starting any non-trivial work.
---
## Before You Touch Any File
Follow this protocol on every edit to avoid the most common session errors:
1. **View before editing.** Always call `view` on a file immediately before
`str_replace`. A previous edit in the same session invalidates earlier view
output — stale context causes "string not found" failures.
2. **One logical change per str_replace.** Don't bundle unrelated edits.
3. **Syntax-check after every Python edit:**
```bash
python3 -c "import ast; ast.parse(open('filename.py').read()); print('OK')"
```
4. **Grep for stale references after renaming or removing anything:**
```bash
grep -n "old_name" main.py renderer.py data.py scanner.py interpolator.py
```
5. **Never assume file content from memory.** If in doubt, view the relevant
section before writing a replacement.
---
## Project Identity
**Name:** pyFi
**Type:** Cross-platform desktop WiFi heatmap generator
**Language:** Python 3.10+
**GUI:** tkinter + matplotlib (TkAgg backend)
**Entry point:** `main.py`
**Branding assets:** `extras/pyfi-logo.ico` (taskbar), `extras/pyfi-mini.png` (sidebar logo)
---
## Project Files
| File | Purpose |
|---|---|
| `main.py` | Tkinter GUI — two-tab layout, auto-scan engine, AP stats, AP placement drag-and-drop |
| `scanner.py` | Cross-platform WiFi scanning with platform-specific backends |
| `interpolator.py` | Spatial interpolation — auto-selects RBF, cubic, or linear |
| `renderer.py` | Ekahau-style RGBA heatmap rendering with signal-driven alpha |
| `data.py` | Session model — measurements, multi-BSSID ops, JSON persistence |
| `requirements.txt` | `numpy`, `scipy`, `matplotlib`, `Pillow` |
| `extras/` | Brand assets (logo, icon, screenshots for README) |
---
## Architecture Overview
pyFi is structured as a clean pipeline:
```
OS WiFi API → scanner.py → data.py (Session) → interpolator.py → renderer.py → tkinter Canvas
```
### `scanner.py` — scanning backends
Each OS uses the most accurate available API, with graceful fallback:
| OS | Primary | Fallback 1 | Fallback 2 |
|---|---|---|---|
| Windows | `wlanapi` via `ctypes` | `netsh wlan show networks` | — |
| Linux | `iw dev scan` | `nmcli dev wifi list` | `iwlist scan` |
| macOS | `airport -s` | — | — |
`scan_averaged(samples=10, delay=0.1)` takes the **median** (not mean) of 10
samples at 100 ms intervals. The median is more robust against RF burst spikes.
**Beacon IE parsing (Windows wlanapi).** The Windows scan list under-reports:
`dot11BssPhyType` caps at HE (10) even for Wi-Fi 7, channel width is never
exposed, and security is only available keyed by SSID (so hidden networks miss
it). To fix all three, the wlanapi backend snapshots each BSS entry's raw
802.11 Information Elements and parses them directly:
| Helper | Reads | Produces |
|---|---|---|
| `_mode_from_ies()` | EHT/HE/VHT/HT **Capabilities** elements | Wi-Fi 4/5/6/6E/7 mode |
| `_security_from_ies()` | RSN (48) + WPA1 vendor (221) elements | WPA2/WPA3/Enterprise/OWE |
| `_width_from_ies()` | EHT/HE/VHT/HT **Operation** elements | 20/40/80/160/320 MHz |
The IE bytes are read once per scan via `ctypes.string_at(base, dwTotalSize)`
(a bounded copy), and every field access is a length-guarded slice of that
`bytes` object — a malformed IE yields a blank result, never an out-of-bounds
read. See Decisions #16#20.
### `data.py` — Session model
Key methods:
- `get_points_and_values(bssid)` — single BSSID signal data
- `get_points_and_values_multi(bssids)` — averaged across multiple BSSIDs;
only BSSIDs visible at each location contribute (absent ones are excluded,
not zeroed)
- `get_missing_points(bssids)` — positions where none of the selected BSSIDs
were visible; rendered as black `?` dots
- `get_anchor_points(bssids)` — synthetic peak points at known AP positions;
injected into interpolation to anchor the heatmap correctly at the transmitter
- `ap_positions: dict[str, tuple[float,float]]` — optional physical position
of each AP on the canvas; saved/loaded with the session JSON
### `interpolator.py` — method selection
| Points available | Method |
|---|---|
| < 4 | No heatmap (progress indicator shown) |
| 48 | Cubic (Delaunay triangulation via scipy `griddata`) |
| 9+ | RBF — Radial Basis Function, thin-plate spline kernel |
The 4-point minimum exists because `griddata(method='cubic')` and even
`method='linear'` use Qhull triangulation which raises `QH6214` with fewer
than 4 non-collinear points.
### `renderer.py` — Ekahau-style RGBA rendering
The heatmap is a `uint8` RGBA numpy array, not a matplotlib `ScalarMappable`.
**Colormap** (`SIGNAL_CMAP`): violet (-90 dBm) → blue → teal → green →
yellow-green → yellow → orange → red (-30 dBm). Warm = strong, cool = weak.
**Alpha channel**: per-pixel cosine ease-in-out ramp between `FADE_FLOOR`
(-85 dBm, fully transparent) and VMAX (-30 dBm, fully opaque). A Gaussian
blur (`sigma=8`) is applied to alpha to soften zone edges and blend overlapping
AP coverage bubbles.
**Colorbar**: drawn by `_draw_colorbar()` using a direct `imshow` of a 256×1
gradient on a dedicated `cax` axes. Does NOT use `fig.colorbar()` with a
`ScalarMappable` — see Decision #11.
**AP markers**: `_draw_ap_markers()` draws a diamond icon (pulse ring + outer
white diamond + signal-colored inner diamond + WiFi arcs + SSID label) at each
pinned AP position. Visually distinct from measurement dots (circles).
---
## Key Design Decisions
### Decision #1 — BSSID as the primary AP identifier, not SSID
SSIDs are not unique. Multiple physical radios (e.g. all nodes in a mesh
network) can broadcast the same SSID. The BSSID (hardware MAC address) is
the only reliable per-radio identifier. All session data, selection state,
and heatmap rendering is keyed on BSSID.
### Decision #2 — Floorplan is optional, not required
The app works without a floorplan using a coordinate grid as the canvas
background. Every surface that touches this state (sidebar label, heatmap
subtitle, export PNG footer) shows a `⚠ Adding a floorplan increases accuracy`
warning to guide users toward the better workflow without blocking them.
### Decision #3 — Minimum 4 points before rendering
The original code used `>= 2` which triggered `QH6214 qhull input error`
from scipy because Delaunay triangulation needs at least 4 non-collinear
points. Raised to 4 as the universal safe floor. A progress bar indicator
`[██░░] 2/4` is shown until the threshold is met.
### Decision #4 — Multi-BSSID averaging excludes absent BSSIDs per point
When multiple BSSIDs are selected, each measurement point averages only the
BSSIDs that were actually visible there. A BSSID that was out of range at a
location is excluded rather than contributing a -90 floor value. This is
critical for mesh networks where different nodes cover different areas.
### Decision #5 — Median instead of mean for scan averaging
WiFi RSSI has burst spikes that can be 1015 dBm away from the true value.
The median discards these outliers. 10 samples at 100 ms intervals
(~1.1 seconds total) catches multiple beacon intervals.
### Decision #6 — wlanapi via ctypes instead of netsh on Windows
`netsh wlan show networks` converts raw RSSI to a 0100% quality score,
which most drivers clamp at -55 dBm → 100% → -50 dBm. This means all
strong signals appear identical. Confirmed empirically by comparing our
readings (-50 dBm everywhere near the router) against NetSpot readings
(-37 to -45 dBm at the same locations). The `wlanapi` `WlanGetNetworkBssList()`
call returns the raw `dot11_RSSI` field with no clamping.
Critical struct detail: `DOT11_SSID` is `{ ULONG uSSIDLength; UCHAR ucSSID[32]; }`
(36 bytes total). The original code had it as `c_ubyte * 33`, treating byte `[0]`
as the length and reading from offset 1. This is wrong — `uSSIDLength` is a
4-byte ULONG, so `ucSSID` starts at offset 4. The misread caused SSIDs to be
truncated (e.g. "NETGEAR22" → "NETGEA").
Mode/security/width are NOT taken from the scan list's struct fields (which
under-report — see Decisions #16#20); they are parsed from each entry's raw
beacon IEs instead.
### Decision #7 — iw instead of nmcli on Linux
`nmcli` reads from NetworkManager's scan cache with its own smoothing.
`iw dev <iface> scan` calls `nl80211` directly, returns signal in mBm
(0.1 dBm precision), and provides raw IE (Information Element) data for
accurate HE/VHT/HT mode detection.
### Decision #8 — Networks never disappear from the AP tab list
Once a BSSID is seen it stays in the table permanently, shown dimmed when
out of range. `_known_bssids_ordered()` merges the current scan with
`_ap_stats` (which accumulates all seen BSSIDs). Stale rows show "out of
range" in the Level column and keep their historical Max/Min/Avg stats.
### Decision #9 — AP tab column alignment via fixed-pixel container frames
tkinter `Label(width=N)` uses character units which vary by font, causing
headers to misalign with data. The fix wraps every cell (both header and
data rows) in a `tk.Frame(width=px, height=ROW_HEIGHT)` with
`pack_propagate(False)`, then uses `place(relwidth=1, relheight=1)` for the
label inside. Pixel widths are defined once in `AP_COLUMNS` and used
identically for both header and data rows.
### Decision #10 — Colorbar stacking fix via dedicated cax axes
`fig.colorbar(ax=ax)` steals a fraction of `ax`'s position on each call.
`ax.clear()` resets content but not position, so the axes shrank ~3% per
redraw. Fixed by: (1) calling `fig.subplots_adjust(right=0.88)` once at
figure creation to permanently reserve colorbar space, and (2) using
`fig.add_axes([0.91, 0.15, 0.02, 0.70])` with a dedicated `cax` so
matplotlib never touches the main axes position.
### Decision #11 — Colorbar rendered as direct imshow, not fig.colorbar()
`fig.colorbar()` with a `ScalarMappable(set_array([]))` renders as a white
rectangle on some matplotlib/backend combinations because the mappable has
no associated axes and no data range that all backends interpret consistently.
The fix: `_draw_colorbar()` calls `cax.imshow()` directly with a 256×1 gradient
array mapped through `SIGNAL_CMAP`. This is unconditional and backend-agnostic.
### Decision #12 — Ekahau-style signal-driven alpha (bubble effect)
The original renderer used a flat `alpha=0.65` scalar, producing a solid
rectangular overlay with no zone definition. The new approach builds a `uint8`
RGBA array where the alpha channel is a per-pixel cosine ramp based on signal
strength. Weak zones fade to transparent (showing the floorplan through),
strong zones stay opaque. A Gaussian blur on the alpha channel blends
overlapping coverage bubbles. This matches the visual output of professional
tools like Ekahau and NetSpot.
### Decision #13 — AP position anchoring for interpolation accuracy
Without knowing where APs physically are, the interpolator estimates signal
peaks from surrounding measurement points — which can place the peak in the
wrong location, especially in mesh systems with uneven measurement density.
`get_anchor_points(bssids)` injects a synthetic point at each pinned AP
position with value `min(-25, max_real_reading + 5)`. This anchors the
heatmap peak at the transmitter location without appearing as a measurement dot.
### Decision #14 — PyInstaller asset path resolution
`os.path.dirname(__file__)` does not work in PyInstaller bundles because
`__file__` points to the temp extraction directory, not the source tree.
`_resource_path(relative)` uses `getattr(sys, "_MEIPASS", os.path.dirname
(os.path.abspath(__file__)))` to resolve correctly in both source and compiled
modes. All asset paths go through this helper.
### Decision #15 — Mesh network uniformity problem
When a mesh system (e.g. Netgear RS500 + Orbi RBK750 with two satellites) all
broadcast the same SSID, the device silently roams between nodes. Measurements
always land on whichever node is currently serving the device, which is always
the strongest one nearby. This produces uniform readings across the entire floor
because the device is never far from a strong signal. The solution is to select
individual BSSIDs (one per radio) on the Access Points tab and map each node
separately, or temporarily disable other nodes during a single-radio session.
### Decision #16 — Wi-Fi 6E / 7 and the 6 GHz band
Mode detection covers `n/ac/ax/ax(6E)/be` (Wi-Fi 4 → 7). The band is derived
from **frequency**, not channel number, because 6 GHz channel numbers overlap
with 2.4/5 GHz ones — `_band_from_freq()` maps 59257125 MHz → "6 GHz" and is
preferred over `_band_from_channel()` whenever a frequency is known. Channel
width adds **320 MHz** (Wi-Fi 7 only).
### Decision #17 — Mode comes from beacon IEs, not `dot11BssPhyType`
The Windows `WlanGetNetworkBssList` `dot11BssPhyType` field **caps at HE (10)
and never reports EHT (11)**, even for 802.11be APs — only the connected-
association path (`netsh wlan show interfaces`) surfaces `802.11be`. So Wi-Fi 7
is detectable from a scan *only* by parsing the **EHT Capabilities** element
(Element ID 255, Ext ID 108). `_mode_from_ies()` checks EHT(108) → Wi-Fi 7,
HE(35) [+ 6 GHz freq or HE-6GHz-band-cap (59)] → Wi-Fi 6E/6, VHT(191) → 5,
HT(45) → 4. The `dot11BssPhyType` map remains only as a fallback — and its
enum was corrected (7=HT/n, 8=VHT/ac, 9=DMG, 10=HE/ax, 11=EHT/be; the old map
was off by one for 7/8/9).
### Decision #18 — `WLAN_BSS_ENTRY` struct must be byte-exact for IE access
The original ctypes `WLAN_BSS_ENTRY` had `bInRegDomain` as `BOOL` (4 bytes; it
is `BOOLEAN` = 1 byte) and `wlanRateSet` as `c_ubyte * 16` (it is
`WLAN_RATE_SET` = **256 bytes**). Those fields precede `ulIeOffset`/`ulIeSize`,
so both were read 240+ bytes off — pointing at garbage. This is why an early
attempt to read IEs via `from_address()` **crashed the app**. Fixed layout:
`sizeof = 360`, `ulIeOffset` at offset 352. (RSSI/freq/phy sit *before* the
broken fields, which is why scanning itself always worked.) Verify any struct
change with `ctypes.sizeof` + field `.offset` against these numbers.
### Decision #19 — Security from RSN IE; hidden networks via Privacy bit
Security resolves in three tiers: (1) SSID-keyed `dot11DefaultAuthAlgorithm`
from `WlanGetAvailableNetworkList`; (2) for hidden networks (no SSID to key
on), `_security_from_ies()` parses the **RSN element (48)** AKM suite types —
PSK(2/4/6)→WPA2, SAE(8/9)→WPA3, 802.1X(1/3/5/1113)→Enterprise, OWE(18) — plus
the WPA1 vendor IE (221, OUI 00:50:F2:01); (3) the **Privacy bit** (0x10) of
`usCapabilityInformation` → "Secured"/"Open". RSN field offsets are exact:
within the element body, pairwise-cipher count is at offset **6** (after
version[2] + group-cipher[4]), AKM list follows the pairwise list.
### Decision #20 — Channel width from Operation elements; "Local" vendor
`_width_from_ies()` parses width from the **Operation** elements (the scan
list never exposes it), newest generation winning: EHT Operation (Ext 106,
3-bit width 04 = 20/40/80/160/320) → HE Operation (Ext 36; 6 GHz Op Info or
embedded VHT Op Info) → VHT Operation (El 192, width+seg0/seg1) → HT Operation
(El 61, secondary-offset + width-any → 20/40). All reads are length-guarded.
The **HT, VHT, and HE** (incl. 6 GHz Op Info) offsets are validated byte-for-byte
against the Linux kernel headers — HE matches `ieee80211_he_6ghz_oper()` exactly
(`optional[]` at struct offset 6 → `body[7]`; `VHT_OPER_INFO=0x4000`,
`CO_HOSTED_BSS=0x8000`, `6GHZ_OP_INFO=0x20000`; 6 GHz `control & 0x3` →
20/40/80/160). The **EHT** path (Ext 106 → `params`[1] bit0 = info-present,
`basic_mcs_nss`[4], `control` at `body[6]`, 3-bit width 04 = 20/40/80/160/320)
is confirmed only against the spec encoding — no `ieee80211-eht.h` was available
to cross-check; re-verify against the kernel `ieee80211_eht_operation` /
`ieee80211_eht_operation_info` structs if touched. Separately, `lookup_vendor()`
returns **"Local"** for locally-administered BSSIDs (U/L bit 0x02 set in the
first octet) — these are the randomized virtual/guest/mesh BSSIDs a single radio
spawns, whose OUI is not a real IEEE assignment.
---
## Known Issues & Gotchas
**Windows 11 24H2+**: Microsoft requires Location Services to be enabled for
WiFi scanning APIs to return BSSID data. This is an OS restriction, not a
pyFi bug. Users must enable Location Services in Settings.
**tkinter PhotoImage garbage collection**: If a `PhotoImage` object goes out of
scope, Python's GC destroys it and the label shows blank. Always store a
reference on the parent widget (e.g. `f._logo_img_ref = img`).
**`RegularPolygon` orientation**: matplotlib's `RegularPolygon` with
`numVertices=4` defaults to a square (flat-bottom). `orientation=np.pi/4`
rotates it 45° to produce a diamond shape.
**Duplicate `_draw_ap_markers`**: At one point the renderer had two definitions
of this function (the second silently overwriting the first) AND neither was
being called from `render_heatmap`. Both issues were resolved in the same fix.
**Auto-scan vs placement scan conflict**: If a placement scan (`_scan_for_placement`)
is in progress when the auto-scan timer fires, `_fire_auto_scan` detects
`self.scanning == True` and reschedules without firing, preventing a race condition.
**BSSID SSID truncation (Windows)**: The `DOT11_SSID` struct was originally
defined as `c_ubyte * 33` which misread the 4-byte `uSSIDLength` field.
This truncated SSIDs to their first 6 bytes. Fixed by defining a proper
`DOT11_SSID` ctypes Structure.
**Wi-Fi 7 not seen in scans**: If an 802.11be AP shows as Wi-Fi 6/6E, the cause
is almost always the `dot11BssPhyType` HE cap (Decision #17) — confirm the EHT
Capabilities IE (Ext 108) is actually being parsed, not the phy-type field.
`netsh wlan show interfaces` showing `802.11be` does NOT mean the scan list
does; it uses a different (connected-association) code path.
**OUI vendor database**: `lookup_vendor()` is backed by the full IEEE OUI CSV
(~37k entries), downloaded once to `~/.pyfi/oui.csv` and refreshed every 30
days. The download runs in a daemon thread started at module import, so the
*first ever* scan (cold cache, before the download finishes) may briefly show
`-`/built-in-table vendors; subsequent scans have the full DB. A small
built-in table (`_OUI_BUILTIN`) is the offline fallback. Locally-administered
BSSIDs short-circuit to "Local" before any lookup.
**Show Hidden toggle**: The AP tab has a "Show Hidden" checkbox (default off)
that filters rows whose SSID is `<hidden>`/empty. `_populate_ap_table` skips
them using a separate `visible_idx` counter for row numbering/striping —
`_ap_row_widgets` stays keyed by BSSID, so Select All / toggles remain correct.
---
## AP_COLUMNS — Canonical Column Definition
This list in `main.py` drives pixel widths for both the header row and every
data row in the Access Points table. Header and data cells use identical
fixed-pixel container frames — any mismatch causes misalignment. Always update
both if adding a column.
```python
AP_COLUMNS = [
("sel", "✓", 38),
("ssid", "SSID", 160),
("bssid", "BSSID", 145),
("channel", "Ch", 38),
("freq", "Frequency", 90),
("width", "Ch Width", 75),
("band", "Band", 60),
("security", "Security", 160),
("vendor", "Vendor", 90),
("mode", "Mode", 90),
("level", "Level", 110), # canvas-drawn signal bar, not a Label
("last_seen", "Last Seen", 75),
("max_dbm", "Max", 50),
("min_dbm", "Min", 50),
("avg_dbm", "Avg", 50),
]
```
The `level` column is special — it's a `tk.Canvas` widget drawn by
`_draw_signal_bar()`, not a `_cell()` label. It must be handled separately
in `_populate_ap_table`.
---
## Heatmap Tab State Variables
These variables on `WiFiHeatmapApp` interact during scanning and AP placement.
Understand their invariants before modifying either workflow.
| Variable | Type | Invariant |
|---|---|---|
| `_placement_mode` | `bool` | True only between `_ready_to_place()` and the canvas click that records the measurement |
| `_pending_scan` | `list[AccessPoint] \| None` | Holds scan results while `_placement_mode` is True; cleared on canvas click |
| `_ap_drag_mode` | `bool` | True while the "Place APs" button is active; toggled by `_toggle_ap_drag_mode()` |
| `_dragging_bssid` | `str \| None` | The BSSID armed for placement; set by `_select_ap_for_pin()`, cleared after a drop |
| `_ap_drag_preview` | `list \| None` | Temporary crosshair artists on the canvas; removed on next motion event |
| `scanning` | `bool` | True during any background scan thread; guards against concurrent scans |
| `selected_bssids` | `set[str]` | BSSIDs checked on the AP tab; source of truth for selection |
| `selected_bssids_for_heatmap` | `list[str]` | Ordered list derived from `selected_bssids` by `_sync_heatmap_bssids()`; passed to renderer |
**Critical invariant**: `scanning` must be set to `True` before spawning a
background thread and back to `False` (on the main thread via `root.after`)
when done. Never set it from the background thread directly.
**Critical invariant**: `_placement_mode` and `_ap_drag_mode` are mutually
exclusive in practice — `_on_canvas_click` checks `_ap_drag_mode` first and
returns early, so a placement click is only processed when drag mode is off.
---
## Matplotlib Figure Structure
The figure is created once in `_build_heatmap_tab` and reused across all
redraws. Its structure must be respected:
- `fig.axes[0]` — always the main plot (`ax`). Never remove this.
- `fig.axes[1:]` — colorbar axes from previous renders. These are removed at
the top of every `render_heatmap` call with `for ax_obj in fig.axes[1:]: ax_obj.remove()`.
- `fig.subplots_adjust(right=0.88)` — called once at figure creation. Reserves
the right 12% for the colorbar permanently. Do not call this on every render
or it will compound.
- Colorbar `cax` position: `[0.91, 0.15, 0.025, 0.70]` in figure-fraction
units (left, bottom, width, height). Created fresh each render by
`_draw_colorbar()`.
**Do not pass `ax=ax` to `fig.colorbar()`**. This steals space from `ax` on
every call and the axes shrinks permanently. Always use `cax=` with a
pre-made axes, or use `_draw_colorbar()` which renders the gradient directly.
---
## Session JSON Schema
```json
{
"name": "string",
"floorplan_path": "string | null",
"canvas_width": 800,
"canvas_height": 600,
"created_at": "ISO datetime string",
"updated_at": "ISO datetime string",
"ap_positions": {
"AA:BB:CC:DD:EE:FF": [123.4, 456.7]
},
"measurements": [
{
"x": 123.4,
"y": 456.7,
"signals": { "AA:BB:CC:DD:EE:FF": -52 },
"ssid_map": { "AA:BB:CC:DD:EE:FF": "MyNetwork" },
"timestamp": "ISO datetime string"
}
]
}
```
**Backward compatibility rule**: every field read in `Session.load()` must use
`data.get("field", default)` or have an explicit `if "field" in data` guard.
Sessions saved before a new field was added must still load without error.
`ap_positions` is an example — it was added after initial sessions existed,
so `load()` uses `data.get("ap_positions", {})`.
---
## What Could Reasonably Be Unit-Tested
There is currently no automated test suite. These areas are well-suited for
testing without requiring OS WiFi hardware or a display:
| Area | What to test |
|---|---|
| `interpolator.py` | Minimum point threshold (3 points raises ValueError, 4 succeeds), method selection (< 9 → cubic, ≥ 9 → rbf), NaN fill at edges |
| `data.py — get_points_and_values_multi` | Single BSSID equals `get_points_and_values`, absent BSSID excluded not zeroed, empty selection returns empty lists |
| `data.py — get_missing_points` | Returns positions where no selected BSSID was seen, returns empty when all BSSIDs visible |
| `data.py — get_anchor_points` | Returns empty when no AP positions set, synthetic value is `min(-25, max_reading + 5)` |
| `data.py — Session save/load` | Round-trip preserves all fields, missing `ap_positions` key loads as empty dict |
| `scanner.py — scan_averaged median` | Given known readings, median is returned not mean; single-sample case returns that value |
| `scanner.py — _channel_from_freq` | 2437 → channel 6, 5180 → channel 36, 2484 → channel 14 |
| `scanner.py — _band_from_freq` | 2422 → 2.4 GHz, 5180 → 5 GHz, 6295 → 6 GHz |
| `scanner.py — _mode_from_ies` | EHT cap → Wi-Fi 7, HE cap + 6 GHz → 6E, VHT → 5, HT → 4, empty → "" |
| `scanner.py — _security_from_ies` | RSN AKM PSK → WPA2, SAE → WPA3, both → WPA2/WPA3, 802.1X → Enterprise, WPA1 vendor IE → WPA-Personal |
| `scanner.py — _width_from_ies` | EHT width 4 → 320, 3 → 160; VHT seg gap 8 → 160; HT sec+wide → 40; EHT wins over VHT/HT |
| `scanner.py — lookup_vendor` | U/L bit set (e.g. D6:…) → "Local"; globally-unique OUI → vendor name |
| `main.py — _resource_path` | When `sys._MEIPASS` is not set, returns path relative to `__file__`; when set, returns path relative to `_MEIPASS` |
| `renderer.py — _build_ekahau_rgba` | Output shape is `(H, W, 4)` uint8, NaN cells have alpha=0, strong signal cells have high alpha |
**What cannot reasonably be unit-tested without significant mocking:**
- Actual WiFi scanning (requires OS API and a WiFi adapter)
- tkinter GUI behavior (requires a display; `tkinter.Tcl()` can test some logic but is fragile)
- Matplotlib rendering output (visual; would need image comparison with tolerances)
- PyInstaller bundle behavior
---
## Known Fragile Areas
These are not bugs but structural fragilities where a well-intentioned change
is likely to break something non-obvious.
**`_build_heatmap_right` row numbers are manual.**
Grid rows are numbered 012 explicitly. Inserting a new panel requires
renumbering all subsequent `grid(row=N)` calls. Miss one and widgets overlap
silently or disappear. Always audit all row numbers in the method after any
insertion.
**`_ap_row_widgets` must stay in sync with visual rows.**
`_ap_select_all` and `_ap_deselect_all` iterate `_ap_row_widgets`. If
`_populate_ap_table` ever returns early or skips a row without appending to
`_ap_row_widgets`, the checkbox state will be wrong for all subsequent rows.
**`_known_bssids_ordered()` determines AP table row order.**
This method defines which BSSIDs appear and in what order. `_ap_row_widgets[i]`
corresponds to `_known_bssids_ordered()[i]`. If the ordering changes between
the time rows are built and the time a checkbox callback fires, the wrong BSSID
gets toggled. The ordering is stable (current scan order → stale by last-seen
descending) but must remain deterministic.
**`scan_averaged` delay × samples = wall-clock time.**
Default is 10 × 0.1s ≈ 1.1s. The AP tab auto-scan also uses this. If the
interval is set to 3 seconds (the minimum) and scanning takes 1.1 seconds,
the app is scanning 37% of the time. Increasing samples or delay without
considering the auto-scan interval can make the UI feel unresponsive.
**`fig.subplots_adjust(right=0.88)` is called only at figure creation.**
The figure is created in `_build_heatmap_tab`. If the figure is ever recreated
(e.g. to change figure size), `subplots_adjust` must be called again or the
colorbar will overlap the main plot.
**The `DOT11_SSID` ctypes struct layout is exact.**
`uSSIDLength` is a 4-byte `ULONG`. `ucSSID` is 32 bytes. Total 36 bytes. If
this struct is ever modified or the field types changed, SSIDs will silently
truncate or corrupt. The original bug (treating the struct as `c_ubyte * 33`)
produced SSIDs truncated to 6 characters. Test any struct change against a
network with a known SSID length > 6 characters.
**The `WLAN_BSS_ENTRY` ctypes struct layout is exact (and IE-critical).**
`bInRegDomain` is `BOOLEAN` (1 byte) and `wlanRateSet` is `WLAN_RATE_SET`
(256 bytes). Getting either wrong shifts `ulIeOffset`/`ulIeSize` and makes all
beacon-IE parsing (mode, security, width) read garbage — and a raw read at a
bad offset crashes the app. Invariants: `ctypes.sizeof(WLAN_BSS_ENTRY) == 360`,
`ulIeOffset` at offset 352, `ulIeSize` at 356, `lRssi` at 56,
`ulChCenterFrequency` at 92. Verify with `.offset` after any change (see
Decision #18). Note RSSI/freq/phy precede the IE fields, so a broken layout
still scans fine — only IE-derived columns break, which masks the bug.
**Beacon-IE parsers assume byte-exact element offsets.**
`_mode_from_ies`, `_security_from_ies`, `_width_from_ies` index into raw IE
bytes at fixed offsets derived from the 802.11 / Linux `ieee80211.h` layouts
(e.g. RSN pairwise count at body offset 6; EHT Operation control at body[6];
HE `he_oper_params` is a 4-byte LE field with VHT=0x4000 / 6GHz=0x20000 flags).
All accesses are length-guarded so bad data yields "" not a crash, but a wrong
offset silently mislabels. The HT/VHT/HE width offsets have been validated
byte-for-byte against the Linux kernel `ieee80211-ht.h` / `-vht.h` / `-he.h`
definitions (HE matches `ieee80211_he_6ghz_oper()` exactly); the **EHT** path
is the one still unverified against source. When touching these, diff against
the kernel `ieee80211_*_operation` structs and test on a live network of the
relevant generation.
---
There is no automated test suite. Testing is done by:
1. Running the app and manually scanning
2. Exporting PNG and comparing measurement dots against known AP locations
3. Comparing readings against NetSpot (reference tool) at the same locations
4. Checking session JSON files for correct data structure after save/load cycles
---
## Build & Distribution
**Development:**
```bash
pip install -r requirements.txt
python main.py
```
**Windows binary:**
```bash
pyinstaller --onefile --windowed \
--icon "extras/pyfi-logo.ico" \
--add-data "extras/pyfi-mini.png;extras" \
--add-data "extras/pyfi-logo.ico;extras" \
main.py
```
All asset paths inside the binary use `_resource_path()` which resolves
`sys._MEIPASS` at runtime.
---
## Session Workflow (for context in future sessions)
1. User scans on **Access Points** tab — discovers BSSIDs, sees SSID/channel/
security/vendor/mode/level/last-seen/max/min/avg per network. Checkboxes
select which BSSIDs to include in the heatmap. Auto-scan runs on a
user-configurable interval (default 10s, min 3s).
2. User switches to **Heatmap** tab — active BSSIDs shown in the right panel.
Single BSSID = individual radio map. Multiple BSSIDs = averaged coverage map.
3. User optionally pins AP physical locations with **◆ Place APs** — click a
network row to arm it, click its location on the canvas to drop the pin.
Pins are saved with the session and inject anchor points into interpolation.
4. User walks the space clicking **▶ Scan & Place** at each location. After 4+
valid measurements the heatmap renders automatically with Ekahau-style
signal bubbles.
5. Black `?` dots mark locations where the selected BSSID(s) were completely
out of range. These are important dead-zone indicators.
6. Sessions save as `.json` and restore all measurements, AP positions, and
floorplan path.
+28 -5
View File
@@ -27,6 +27,11 @@ Dependencies: `numpy`, `scipy`, `matplotlib`, `Pillow`
No third-party WiFi libraries are required. The scanner uses native OS APIs
directly (`wlanapi` on Windows, `iw` / `nmcli` on Linux, `airport` on macOS).
On the first scan the vendor lookup downloads the IEEE OUI registry (~37,000
entries) to `~/.pyfi/oui.csv` and refreshes it every 30 days. This is the only
network access pyFi makes; if it's offline a small built-in vendor table is
used as a fallback.
---
## 📖 Usage 📖
@@ -76,9 +81,9 @@ headers are pixel-exact aligned to their data columns.
| Frequency | Centre frequency in MHz |
| Ch Width | Channel width (20 / 40 / 80 / 160 / 320 MHz) |
| Band | 2.4, 5, or 6 GHz |
| Security | WPA3-Personal, WPA2-Personal, WPA2-Enterprise, WPA-Personal, WEP, Open |
| Vendor | Manufacturer derived from BSSID OUI prefix (~100 vendors recognized) |
| Mode | 802.11 mode: ax (Wi-Fi 6), ac (Wi-Fi 5), n (Wi-Fi 4), g, a, b |
| Security | WPA3-Personal, WPA2/WPA3-Personal, WPA2-Personal, WPA3/WPA2-Enterprise, WPA-Personal, OWE (Enhanced Open), WEP, Open |
| Vendor | Manufacturer from the full IEEE OUI registry (~37,000 entries); randomized BSSIDs show "Local" |
| Mode | 802.11 mode: be (Wi-Fi 7), ax (Wi-Fi 6E), ax (Wi-Fi 6), ac (Wi-Fi 5), n (Wi-Fi 4), g, a, b |
| Level | Color-coded signal bar (green ≥ -50, yellow -50-65, orange -65-75, red < -75) |
| Last Seen | How long ago this BSSID was last visible (updates each scan) |
| Max | Strongest signal ever recorded for this BSSID across all scans |
@@ -98,6 +103,9 @@ Avg is color-coded dynamically by signal quality.
maximum 300 seconds). A live countdown label shows time until the next scan.
While auto-scan is active the manual Refresh button is disabled to prevent
scan conflicts. Changing the interval mid-countdown restarts the timer.
- **Show Hidden** — Off by default. When unchecked, networks broadcasting no
SSID (shown as `<hidden>`) are filtered out of the table; check it to reveal
them. Toggling rebuilds the table instantly without rescanning.
### Tab 2 — Heatmap
@@ -263,6 +271,15 @@ The `DOT11_SSID` struct is read using the correct SDK layout (`uSSIDLength` as
a 4-byte ULONG + `ucSSID[32]`), ensuring SSIDs are never truncated regardless
of length.
Mode, security, and channel width are parsed from each BSS entry's raw beacon
Information Elements rather than the scan list's summary fields, which
under-report. In particular, Windows' `dot11BssPhyType` caps at HE and never
reports EHT, so **Wi-Fi 7 (802.11be) is detected from the EHT Capabilities
element**, channel width (including 320 MHz) from the EHT/HE/VHT/HT Operation
elements, and security — including for hidden networks that have no SSID to
look up — from the RSN element. Reading these requires a byte-exact
`WLAN_BSS_ENTRY` struct so the IE offset is correct.
**Linux — `iw` vs `nmcli`:**
`nmcli` reads from NetworkManager's internal scan cache and applies its own
@@ -270,7 +287,7 @@ signal smoothing. `iw dev <iface> scan` calls the kernel's `nl80211` layer
directly via netlink, forcing a live hardware scan and returning signal in mBm
(millibelsmilliwatt) at 0.1 dBm precision, e.g. -6500 = -65.0 dBm. It also
returns raw Information Elements (IEs) from the beacon frame, enabling accurate
detection of HE / VHT / HT capabilities for the Mode column.
detection of EHT / HE / VHT / HT capabilities for the Mode column.
### Linux permissions for `iw`
@@ -380,7 +397,7 @@ or **💾 Save As…** from the sidebar.
| File | Purpose |
|---|---|
| `main.py` | Tkinter GUI — two-tab layout (Access Points + Heatmap), sidebar, auto-scan engine, AP stats tracking, AP placement drag-and-drop |
| `scanner.py` | Cross-platform WiFi scanning with `wlanapi` / `iw` / `nmcli` / `airport` backends, median averaging, correct DOT11_SSID struct |
| `scanner.py` | Cross-platform WiFi scanning with `wlanapi` / `iw` / `nmcli` / `airport` backends, median averaging, beacon-IE parsing for mode/security/width (Wi-Fi 47), full IEEE OUI vendor lookup |
| `interpolator.py` | Spatial interpolation — auto-selects RBF, cubic, or linear based on point count |
| `renderer.py` | Ekahau-style RGBA heatmap rendering — signal-driven alpha, Gaussian zone blending, AP diamond markers, colorbar |
| `data.py` | Session model — measurements, multi-BSSID averaging, missing-point detection, AP position anchoring, JSON persistence |
@@ -390,6 +407,12 @@ or **💾 Save As…** from the sidebar.
## 🖼️ Screenshots 🖼️
### Single BSSID
<p align="center">
<img src="extras/example_export.png" alt="An image of the wifi heatmap generator heatmap export">
</p>
### Single BSSID w/AP
<p align="center">
<img src="extras/example_export_with_ap.png" alt="An image of the wifi heatmap generator heatmap export with access point">
</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+4 -4
View File
@@ -7,8 +7,8 @@ VSVersionInfo(
ffi=FixedFileInfo(
# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4)
# Set not needed items to zero 0. Must always contain 4 elements.
filevers=(2026,5,3,0),
prodvers=(2026,5,3,0),
filevers=(2026,6,0,0),
prodvers=(2026,6,0,0),
# Contains a bitmask that specifies the valid bits 'flags'r
mask=0x3f,
# Contains a bitmask that specifies the Boolean attributes of the file.
@@ -32,12 +32,12 @@ VSVersionInfo(
u'040904B0',
[StringStruct(u'CompanyName', u''),
StringStruct(u'FileDescription', u'pyFi: WiFi Heatmap Generator'),
StringStruct(u'FileVersion', u'2026.5.3.0'),
StringStruct(u'FileVersion', u'2026.6.0.0'),
StringStruct(u'InternalName', u'pyFi'),
StringStruct(u'LegalCopyright', u'© iamdoubz'),
StringStruct(u'OriginalFilename', u'pyFi.exe'),
StringStruct(u'ProductName', u'pyFi'),
StringStruct(u'ProductVersion', u'2026.5.3.0')])
StringStruct(u'ProductVersion', u'2026.6.0.0')])
]),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
+72 -13
View File
@@ -1,5 +1,5 @@
"""
main.py — WiFi Heatmap Generator GUI
main.py — pyFi WiFi Heatmap Generator GUI
Cross-platform tkinter application for collecting and visualizing WiFi signal data.
Usage:
@@ -14,6 +14,7 @@ import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import threading
import os
import sys
import argparse
import time
from dataclasses import dataclass, field
@@ -30,6 +31,19 @@ from data import Session, Measurement
from renderer import render_heatmap, SIGNAL_CMAP
def _resource_path(relative_path: str) -> str:
"""
Resolve a path to a bundled resource at runtime.
PyInstaller extracts bundled files to a temporary folder at runtime and
sets sys._MEIPASS to that folder's path. When running from source,
the directory containing main.py is used instead — so both the compiled
binary and plain `python main.py` resolve assets correctly.
"""
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, relative_path)
# ── Palette ───────────────────────────────────────────────────────────────────
BG = "#0d0d1a"
BG2 = "#131325"
@@ -109,7 +123,7 @@ AP_COLUMNS = [
class WiFiHeatmapApp:
def __init__(self, root: tk.Tk, initial_session: Optional[str] = None):
self.root = root
self.root.title("WiFi Heatmap Generator")
self.root.title("pyFi")
self.root.configure(bg=BG)
self.root.minsize(1200, 720)
@@ -183,9 +197,21 @@ class WiFiHeatmapApp:
def _build_sidebar(self):
f = self.sidebar
tk.Label(f, text="📡", font=("Segoe UI Emoji", 28), bg=BG2, fg=ACCENT).pack(pady=(20, 4))
tk.Label(f, text="WiFi Heatmap", font=("Georgia", 13, "bold"), bg=BG2, fg=TEXT).pack()
tk.Label(f, text="Generator", font=("Georgia", 11), bg=BG2, fg=TEXT_DIM).pack(pady=(0, 16))
# Logo image — load pyfi-mini.png, fall back to emoji if file is missing
try:
_logo_img = tk.PhotoImage(file=_resource_path(
os.path.join("extras", "pyfi-mini.png")))
# Use a single divisor for both axes to preserve aspect ratio.
# Target ~64px on the longest side.
_divisor = max(1, max(_logo_img.width(), _logo_img.height()) // 92)
_logo_img = _logo_img.subsample(_divisor, _divisor)
tk.Label(f, image=_logo_img, bg=BG2).pack(pady=(20, 4))
f._logo_img_ref = _logo_img # prevent garbage collection
except Exception:
tk.Label(f, text="📡", font=("Segoe UI Emoji", 28), bg=BG2, fg=ACCENT).pack(pady=(20, 4))
tk.Label(f, text="WiFi", font=("Georgia", 13, "bold"), bg=BG2, fg=TEXT).pack()
tk.Label(f, text="Heatmap Generator", font=("Georgia", 10), bg=BG2, fg=TEXT_DIM).pack(pady=(0, 16))
ttk.Separator(f, orient="horizontal").pack(fill="x", padx=12, pady=4)
@@ -296,6 +322,18 @@ class WiFiHeatmapApp:
tk.Label(auto_bar, text="seconds",
font=("Courier", 8), bg=BG3, fg=TEXT_DIM).pack(side="left", padx=(2, 16))
# Show Hidden toggle — when unchecked, networks with no SSID ("<hidden>")
# are filtered out of the table. Defaults to unchecked.
self._show_hidden_var = tk.BooleanVar(value=False)
self.show_hidden_chk = tk.Checkbutton(
auto_bar, variable=self._show_hidden_var,
text="Show Hidden", font=("Courier", 8),
bg=BG3, fg=ACCENT2, selectcolor=BG2,
activebackground=BG3, activeforeground=ACCENT2,
relief="flat", cursor="hand2",
command=self._on_show_hidden_toggle)
self.show_hidden_chk.pack(side="left", padx=(0, 16))
self.auto_scan_countdown = tk.Label(auto_bar, text="",
font=("Courier", 8, "bold"), bg=BG3, fg=WARNING)
self.auto_scan_countdown.pack(side="left")
@@ -422,14 +460,25 @@ class WiFiHeatmapApp:
now = time.monotonic()
all_bssids = self._known_bssids_ordered()
for i, bssid in enumerate(all_bssids):
show_hidden = self._show_hidden_var.get()
visible_idx = 0
for bssid in all_bssids:
ap = current_map.get(bssid)
stats = self._ap_stats.get(bssid)
is_live = ap is not None
row_num = i + 1 # row 0 is the header
row_bg = BG if i % 2 == 0 else BG2
# Resolve SSID early so hidden networks can be filtered before any
# widgets are built. Use last-known SSID from stats when AP is stale.
ssid = ap.ssid if ap else self._ap_stats_ssid(bssid)
# "Show Hidden" unchecked → skip networks broadcasting no SSID.
if not show_hidden and (not ssid or ssid == "<hidden>"):
continue
row_num = visible_idx + 1 # row 0 is the header
row_bg = BG if visible_idx % 2 == 0 else BG2
dim_fg = "#3a3a5a" # very dim — used for stale rows
visible_idx += 1
row = tk.Frame(self.ap_inner, bg=row_bg)
row.grid(row=row_num, column=0, sticky="ew")
@@ -453,7 +502,6 @@ class WiFiHeatmapApp:
anchor="w", padx=4).place(relwidth=1, relheight=1)
# Resolve display values — use last-known from stats when AP is stale
ssid = ap.ssid if ap else self._ap_stats_ssid(bssid)
channel = str(ap.channel) if ap and ap.channel else ""
freq = ap.freq_label() if ap else ""
width_s = ap.channel_width or "" if ap else ""
@@ -552,6 +600,10 @@ class WiFiHeatmapApp:
self._sync_heatmap_bssids()
self._update_heatmap_ap_selector()
def _on_show_hidden_toggle(self):
"""Rebuild the AP table when the Show Hidden checkbox changes."""
self._populate_ap_table(self.last_scan)
def _ap_select_all(self):
self.selected_bssids = {r["bssid"] for r in self._ap_row_widgets}
for row in self._ap_row_widgets:
@@ -1330,16 +1382,23 @@ class WiFiHeatmapApp:
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="WiFi Heatmap Generator")
parser = argparse.ArgumentParser(description="pyFi — WiFi Heatmap Generator")
parser.add_argument("--session", help="Path to a .json session file to load on startup")
args = parser.parse_args()
root = tk.Tk()
root.title("WiFi Heatmap Generator")
root.title("pyFi")
# Set taskbar / window icon
_icon_path = _resource_path(os.path.join("extras", "pyfi-logo.ico"))
try:
root.iconbitmap(default="")
root.iconbitmap(default=_icon_path)
except Exception:
pass
try:
_icon_img = tk.PhotoImage(file=_icon_path)
root.iconphoto(True, _icon_img)
except Exception:
pass # icon is cosmetic — silently skip if unavailable
WiFiHeatmapApp(root, initial_session=args.session)
root.protocol("WM_DELETE_WINDOW", lambda: (root.destroy()))
+88 -175
View File
@@ -15,6 +15,8 @@ Ekahau-style rendering:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import matplotlib.patches as mpatches
import matplotlib.path as mpath
from matplotlib.figure import Figure
from matplotlib.axes import Axes
from matplotlib.patches import Circle
@@ -28,8 +30,6 @@ from interpolator import interpolate, HeatmapData
# ── Colormaps ─────────────────────────────────────────────────────────────────
# Ekahau-style: red (excellent) → orange → yellow → green → teal → blue → violet (poor)
# This is the reverse of our old colormap — strong signal is warm, weak is cool.
_EKAHAU_COLORS = [
(0.45, 0.00, 0.55), # -90 dBm violet / purple
(0.10, 0.10, 0.80), # -80 dBm blue
@@ -42,11 +42,6 @@ _EKAHAU_COLORS = [
]
SIGNAL_CMAP = mcolors.LinearSegmentedColormap.from_list("ekahau", _EKAHAU_COLORS)
# Separate single-color black cmap used only for the "no signal" dot markers
_BLACK_CMAP = mcolors.LinearSegmentedColormap.from_list("black_only",
[(0,0,0), (0,0,0)])
# dBm range the colormap spans
VMIN, VMAX = -90, -30
@@ -70,20 +65,7 @@ def render_heatmap(
If any selected BSSIDs have physical positions set in session.ap_positions,
synthetic anchor points are injected into the interpolation so the heatmap
peak is correctly located at the real transmitter position. AP positions
are shown as distinct diamond icons, visually separate from measurement dots.
Args:
session: The data session with measurements.
bssids: List of BSSIDs. Multiple BSSIDs are averaged per point.
fig/ax: Existing figure/axes (creates new if None).
alpha: Maximum opacity of the heatmap overlay (default 0.85).
show_points: Draw measurement dot markers and AP position icons.
show_colorbar: Draw the dBm color scale.
export_path: If set, save figure to this path.
Returns:
(figure, axes, heatmap_data)
peak is correctly located at the real transmitter position.
"""
if not bssids:
raise ValueError("At least one BSSID must be provided.")
@@ -97,13 +79,11 @@ def render_heatmap(
ax_obj.remove()
ax.clear()
points, values = session.get_points_and_values_multi(bssids)
missing_points = session.get_missing_points(bssids)
points, values = session.get_points_and_values_multi(bssids)
missing_points = session.get_missing_points(bssids)
anchor_pts, anchor_vals = session.get_anchor_points(bssids)
w, h = session.canvas_width, session.canvas_height
w, h = session.canvas_width, session.canvas_height
# Merge anchor points into the interpolation input when available.
# Anchors are synthetic — they don't appear as measurement dots.
interp_points = points + anchor_pts
interp_values = values + anchor_vals
@@ -139,15 +119,7 @@ def render_heatmap(
if len(interp_points) >= MIN_POINTS:
heatmap_data = interpolate(interp_points, interp_values, w, h)
grid = heatmap_data.grid_z
rgba = _build_ekahau_rgba(grid, alpha)
_sm = ax.imshow(
grid,
extent=[0, w, 0, h], origin='upper',
cmap=SIGNAL_CMAP, vmin=VMIN, vmax=VMAX,
alpha=0, zorder=0, aspect='auto'
)
rgba = _build_ekahau_rgba(grid, alpha)
ax.imshow(
rgba,
@@ -156,20 +128,7 @@ def render_heatmap(
)
if show_colorbar:
cax = fig.add_axes([0.91, 0.15, 0.02, 0.70])
cbar = fig.colorbar(_sm, cax=cax)
cbar.set_label("Signal Strength (dBm)", fontsize=10)
cbar.ax.tick_params(labelsize=8)
cbar.ax.text(1.4, VMIN, "Poor",
transform=cbar.ax.get_yaxis_transform(),
fontsize=7, color='gray', va='bottom')
cbar.ax.text(1.4, VMAX, "Excellent",
transform=cbar.ax.get_yaxis_transform(),
fontsize=7, color='gray', va='top')
cbar.ax.text(1.4, VMIN - 5, "No signal",
transform=cbar.ax.get_yaxis_transform(),
fontsize=7, color='black', va='top',
bbox=dict(facecolor='white', edgecolor='none', pad=1.5))
_draw_colorbar(fig)
else:
collected = len(interp_points)
@@ -188,10 +147,9 @@ def render_heatmap(
# ── Measurement dots ──────────────────────────────────────────────────────
radius = max(w, h) * 0.012
if show_points:
# Valid signal measurement points — filled circles
for (px, py), dbm in zip(points, values):
norm_val = (dbm - VMIN) / (VMAX - VMIN)
color = SIGNAL_CMAP(norm_val)
color = SIGNAL_CMAP(np.clip(norm_val, 0.0, 1.0))
ax.add_patch(Circle((px, py), radius=radius,
facecolor=color, edgecolor='white',
linewidth=1.5, zorder=5, alpha=0.95))
@@ -199,7 +157,6 @@ def render_heatmap(
ha='center', va='bottom', fontsize=7,
color='white', fontweight='bold', zorder=6)
# Out-of-range measurement points — black circles with "?"
for (px, py) in missing_points:
ax.add_patch(Circle((px, py), radius=radius,
facecolor='black', edgecolor='white',
@@ -208,7 +165,6 @@ def render_heatmap(
ha='center', va='center', fontsize=7,
color='white', fontweight='bold', zorder=6)
# AP position markers — drawn last so they sit on top of everything
_draw_ap_markers(ax, session, bssids, radius)
# ── Labels & axes ─────────────────────────────────────────────────────────
@@ -218,8 +174,9 @@ def render_heatmap(
point_note += f"{len(missing_points)} out of range (●)"
if len(bssids) > 1:
point_note += f" • avg of {len(bssids)} BSSIDs"
if anchor_pts:
point_note += f"{len(anchor_pts)} AP position{'s' if len(anchor_pts) != 1 else ''} pinned (◆)"
n_pinned = sum(1 for b in bssids if b in session.ap_positions)
if n_pinned:
point_note += f"{n_pinned} AP position{'s' if n_pinned != 1 else ''} pinned (◆)"
if not session.floorplan_path:
point_note += " ⚠ No floorplan — adding one increases accuracy"
@@ -238,46 +195,80 @@ def render_heatmap(
return fig, ax, heatmap_data
# ── Colorbar ──────────────────────────────────────────────────────────────────
def _draw_colorbar(fig: Figure):
"""
Draw the signal strength colorbar by rendering the colormap gradient
directly as an imshow onto a dedicated axes.
This approach is backend-agnostic and guaranteed to show color — it does
not rely on fig.colorbar() interpreting a ScalarMappable, which can silently
render white on some matplotlib/backend combinations.
"""
cax = fig.add_axes([0.91, 0.15, 0.025, 0.70])
# Build the gradient image: 256 rows (dBm steps) × 1 column, RGBA
gradient = np.linspace(1.0, 0.0, 256).reshape(256, 1) # top=VMAX, bottom=VMIN
cax.imshow(gradient, aspect='auto', cmap=SIGNAL_CMAP,
extent=[0, 1, VMIN, VMAX], origin='upper')
# Style the axes
cax.yaxis.set_label_position('right')
cax.yaxis.tick_right()
cax.set_ylabel("Signal Strength (dBm)", fontsize=9, color='white', labelpad=8)
cax.tick_params(axis='y', labelsize=7, colors='white', length=3)
cax.tick_params(axis='x', which='both', bottom=False, labelbottom=False)
cax.set_xlim(0, 1)
cax.set_ylim(VMIN, VMAX)
# Spine styling
for spine in cax.spines.values():
spine.set_edgecolor('#444444')
# Qualitative labels
cax.text(1.6, VMAX, "Excellent", transform=cax.get_yaxis_transform(),
fontsize=7, color='#cccccc', va='top')
cax.text(1.6, VMIN, "Poor", transform=cax.get_yaxis_transform(),
fontsize=7, color='#cccccc', va='bottom')
# "No signal" swatch below the bar
cax.text(0.5, VMIN - 3, "No signal",
transform=cax.get_yaxis_transform(),
fontsize=6, color='white', va='top', ha='center',
bbox=dict(facecolor='black', edgecolor='#444444',
pad=2.0, boxstyle='round'))
# ── RGBA builder ──────────────────────────────────────────────────────────────
def _build_ekahau_rgba(grid: np.ndarray, max_alpha: float) -> np.ndarray:
"""
Convert a dBm grid into an RGBA uint8 image with Ekahau-style rendering.
Alpha channel logic:
Alpha channel:
- Signal ≥ VMAX (-30 dBm) → fully opaque (max_alpha)
- Signal ≤ FADE_FLOOR → fully transparent
- Between → smooth cosine ramp so zone edges blend
naturally without a hard cutoff
- Between → cosine ramp for natural bubble-edge blending
A Gaussian blur is applied to the alpha channel before compositing so
overlapping coverage zones blend smoothly rather than showing sharp
interpolation triangle edges.
Gaussian blur on alpha blends overlapping AP coverage zones smoothly.
"""
FADE_FLOOR = -85.0 # dBm at which the overlay becomes fully transparent
BLUR_SIGMA = 8.0 # pixels — controls softness of zone edges
FADE_FLOOR = -85.0
BLUR_SIGMA = 8.0
res_h, res_w = grid.shape
# ── RGB from colormap ─────────────────────────────────────────────────────
norm = np.clip((grid - VMIN) / (VMAX - VMIN), 0.0, 1.0)
rgb_float = SIGNAL_CMAP(norm)[:, :, :3] # (H, W, 3) float 0-1
rgb_float = SIGNAL_CMAP(norm)[:, :, :3]
# ── Alpha from signal strength ────────────────────────────────────────────
# Use a cosine ramp so the transition from opaque to transparent is smooth
# rather than linear (cosine gives a more natural "bubble" appearance).
fade_range = VMAX - FADE_FLOOR
t = np.clip((grid - FADE_FLOOR) / fade_range, 0.0, 1.0)
alpha_raw = 0.5 * (1.0 - np.cos(np.pi * t)) # cosine ease-in-out
# NaN cells (outside interpolation hull) → transparent
alpha_raw = 0.5 * (1.0 - np.cos(np.pi * t))
alpha_raw[np.isnan(grid)] = 0.0
# Gaussian blur softens zone boundaries and blends overlapping AP zones
alpha_blur = gaussian_filter(alpha_raw, sigma=BLUR_SIGMA)
alpha_blur = np.clip(alpha_blur * max_alpha, 0.0, max_alpha)
# ── Assemble RGBA uint8 ───────────────────────────────────────────────────
rgba = np.zeros((res_h, res_w, 4), dtype=np.uint8)
rgba[:, :, 0] = (rgb_float[:, :, 0] * 255).astype(np.uint8)
rgba[:, :, 1] = (rgb_float[:, :, 1] * 255).astype(np.uint8)
@@ -287,72 +278,6 @@ def _build_ekahau_rgba(grid: np.ndarray, max_alpha: float) -> np.ndarray:
return rgba
# ── AP position markers ───────────────────────────────────────────────────────
def _draw_ap_markers(ax: Axes, session: Session, bssids: list[str], radius: float):
"""
Draw a distinct diamond icon at each AP's known physical position.
Visual design (clearly different from measurement dots):
- Outer diamond: white filled, black border — highly visible on any background
- Inner WiFi arc symbol: drawn in the AP's signal color
- SSID label below the icon
- Small "" glyph instead of a dBm number so it's never confused with
a measurement dot
Only BSSIDs in `bssids` that also have an entry in session.ap_positions
are drawn.
"""
import matplotlib.patches as mpatches
import matplotlib.path as mpath
icon_r = radius * 1.6 # diamond half-size — larger than measurement dots
label_r = radius * 2.8 # label offset below center
for bssid in bssids:
if bssid not in session.ap_positions:
continue
px, py = session.ap_positions[bssid]
ssid = session.get_ssid(bssid)
# ── Diamond shape (rotated square) via Path ───────────────────────────
diamond_verts = [
(px, py - icon_r), # top
(px + icon_r, py ), # right
(px, py + icon_r), # bottom
(px - icon_r, py ), # left
(px, py - icon_r), # close
]
diamond_codes = [
mpath.Path.MOVETO,
mpath.Path.LINETO,
mpath.Path.LINETO,
mpath.Path.LINETO,
mpath.Path.CLOSEPOLY,
]
diamond_path = mpath.Path(diamond_verts, diamond_codes)
diamond_patch = mpatches.PathPatch(
diamond_path,
facecolor='white', edgecolor='#111111',
linewidth=2.0, zorder=8, alpha=0.95
)
ax.add_patch(diamond_patch)
# ── WiFi arc symbol (three concentric arcs, bottom-left origin) ───────
# Drawn as text using the Unicode WiFi / signal character
ax.text(px, py, "📶",
ha='center', va='center',
fontsize=max(6, int(icon_r * 0.55)),
zorder=9, alpha=0.85)
# ── SSID label below the diamond ──────────────────────────────────────
ax.text(px, py + label_r, ssid,
ha='center', va='top', fontsize=7,
color='white', fontweight='bold', zorder=9,
bbox=dict(boxstyle='round,pad=0.2',
facecolor='#111111', edgecolor='none', alpha=0.7))
# ── AP position markers ───────────────────────────────────────────────────────
def _draw_ap_markers(ax: Axes, session: Session, bssids: list[str], radius: float):
@@ -360,67 +285,54 @@ def _draw_ap_markers(ax: Axes, session: Session, bssids: list[str], radius: floa
Draw a distinct diamond ◆ icon at each AP whose physical position is known.
Visual design (intentionally different from measurement circles):
- Outer diamond: white-filled rotated square, larger than measurement dots
- Inner diamond: signal-colored fill
- WiFi arc lines above the diamond
- SSID label beneath
- Thin pulse ring to make them easy to spot on a busy floorplan
These markers are purely informational — they are not measurement points
and do not affect the interpolation directly (the anchor injection in
render_heatmap() handles that separately).
- Outer dashed pulse ring
- White-bordered diamond
- Signal-colored inner diamond
- Three WiFi arc lines above
- SSID label below
"""
import matplotlib.patches as mpatches
import matplotlib.transforms as mtransforms
for bssid in bssids:
if bssid not in session.ap_positions:
continue
px, py = session.ap_positions[bssid]
ssid = session.get_ssid(bssid)
r = radius * 1.6 # diamond is larger than measurement dots
px, py = session.ap_positions[bssid]
ssid = session.get_ssid(bssid)
r = radius * 1.6
# ── Outer pulse ring ─────────────────────────────────────────────────
# Pulse ring
ax.add_patch(Circle((px, py), radius=r * 1.9,
facecolor='none', edgecolor='white',
linewidth=0.8, zorder=7, alpha=0.35,
linestyle='--'))
# ── White border diamond ──────────────────────────────────────────────
diamond_outer = mpatches.RegularPolygon(
# Outer white diamond
ax.add_patch(mpatches.RegularPolygon(
(px, py), numVertices=4, radius=r * 1.15,
orientation=np.pi / 4, # rotate 45° → diamond orientation
orientation=np.pi / 4,
facecolor='white', edgecolor='white',
linewidth=0, zorder=8, alpha=0.95)
ax.add_patch(diamond_outer)
linewidth=0, zorder=8, alpha=0.95))
# ── Coloured inner diamond ────────────────────────────────────────────
# Use the anchor dBm value for the color
# Signal-colored inner diamond
real_pts, real_vals = session.get_points_and_values(bssid)
anchor_dbm = min(-25, max(real_vals) + 5) if real_vals else -35
norm_val = np.clip((anchor_dbm - VMIN) / (VMAX - VMIN), 0.0, 1.0)
color = SIGNAL_CMAP(norm_val)
diamond_inner = mpatches.RegularPolygon(
ax.add_patch(mpatches.RegularPolygon(
(px, py), numVertices=4, radius=r * 0.80,
orientation=np.pi / 4,
facecolor=color, edgecolor='none',
zorder=9, alpha=0.95)
ax.add_patch(diamond_inner)
zorder=9, alpha=0.95))
# ── WiFi arcs above the diamond ───────────────────────────────────────
# Three concentric arcs drawn as partial circles
# WiFi arcs
for arc_r, arc_alpha in [(r * 0.55, 0.9), (r * 0.9, 0.65), (r * 1.25, 0.40)]:
arc = mpatches.Arc(
(px, py - r * 0.15), # slightly above centre
ax.add_patch(mpatches.Arc(
(px, py - r * 0.15),
width=arc_r * 2, height=arc_r * 2,
angle=0, theta1=30, theta2=150, # upper semicircle arc
angle=0, theta1=30, theta2=150,
color='white', linewidth=1.4,
zorder=10, alpha=arc_alpha)
ax.add_patch(arc)
zorder=10, alpha=arc_alpha))
# ── SSID label below the marker ───────────────────────────────────────
# SSID label
ax.text(px, py + r * 2.2, ssid,
ha='center', va='top', fontsize=7,
color='white', fontweight='bold', zorder=10,
@@ -428,12 +340,13 @@ def _draw_ap_markers(ax: Axes, session: Session, bssids: list[str], radius: floa
facecolor='#000000', alpha=0.55,
edgecolor='none'))
# ── ◆ glyph as a tiny anchor indicator ───────────────────────────────
# Centre glyph
ax.text(px, py, '',
ha='center', va='center', fontsize=6,
color='white', zorder=11, alpha=0.7)
# ── Grid background ───────────────────────────────────────────────────────────
def _draw_grid_background(ax: Axes, w: int, h: int):
"""Draw a subtle coordinate grid when no floorplan is loaded."""
+406 -38
View File
@@ -23,17 +23,27 @@ is more robust against the burst spikes that WiFi RSSI produces and better
matches what professional tools such as NetSpot report.
"""
import csv
import subprocess
import platform
import re
import threading
import time
import statistics
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
# ── OUI vendor table ──────────────────────────────────────────────────────────
_OUI_TABLE: dict[str, str] = {
# ── OUI vendor lookup — IEEE database with built-in fallback ─────────────────
_OUI_CACHE = Path.home() / ".pyfi" / "oui.csv"
_OUI_URL = "https://standards-oui.ieee.org/oui/oui.csv"
_OUI_MAX_AGE_DAYS = 30
# Small built-in table used until the full IEEE DB loads (or if offline forever)
_OUI_BUILTIN: dict[str, str] = {
"000C29": "Cisco", "001A2F": "Cisco", "00E0F7": "Cisco",
"00173F": "Linksys", "001346": "Linksys",
"001B2F": "Netgear", "00224C": "Netgear", "20E52A": "Netgear",
@@ -44,42 +54,86 @@ _OUI_TABLE: dict[str, str] = {
"107B44": "ASUS", "1C872C": "ASUS", "2C56DC": "ASUS",
"305A3A": "ASUS", "382C4A": "ASUS", "40167E": "ASUS",
"50465D": "ASUS", "6045CB": "ASUS", "AC220B": "ASUS",
"000A27": "Apple", "000A95": "Apple", "001124": "Apple",
"001451": "Apple", "001B63": "Apple", "001CB3": "Apple",
"001E52": "Apple", "001FF3": "Apple", "0021E9": "Apple",
"0023DF": "Apple", "002500": "Apple", "3C0754": "Apple",
"70CD60": "Apple", "A45E60": "Apple", "F0DBF8": "Apple",
"001632": "Samsung", "002339": "Samsung", "5425EA": "Samsung",
"000A27": "Apple", "000A95": "Apple", "001124": "Apple",
"001451": "Apple", "001B63": "Apple", "001CB3": "Apple",
"001E52": "Apple", "001FF3": "Apple", "0021E9": "Apple",
"0023DF": "Apple", "002500": "Apple", "3C0754": "Apple",
"70CD60": "Apple", "A45E60": "Apple", "F0DBF8": "Apple",
"001632": "Samsung", "002339": "Samsung", "5425EA": "Samsung",
"8C7712": "Samsung", "A0821F": "Samsung",
"001195": "D-Link", "0015E9": "D-Link", "001CF0": "D-Link",
"001195": "D-Link", "0015E9": "D-Link", "001CF0": "D-Link",
"1C7EE5": "D-Link", "280DFC": "D-Link",
"002722": "Ubiquiti", "04186A": "Ubiquiti", "0418D6": "Ubiquiti",
"24A43C": "Ubiquiti", "44D9E7": "Ubiquiti", "687278": "Ubiquiti",
"002722": "Ubiquiti", "04186A": "Ubiquiti", "0418D6": "Ubiquiti",
"24A43C": "Ubiquiti", "44D9E7": "Ubiquiti", "687278": "Ubiquiti",
"788A20": "Ubiquiti", "802AA8": "Ubiquiti",
"000B86": "Aruba", "001A1E": "Aruba", "20A6CD": "Aruba",
"000B86": "Aruba", "001A1E": "Aruba", "20A6CD": "Aruba",
"6C3B6B": "Aruba", "94B4CF": "Aruba",
"F88FCA": "Google", "1AC487": "Google", "54607E": "Google",
"F88FCA": "Google", "1AC487": "Google", "54607E": "Google",
"A47733": "Google", "3C5AB4": "Google",
"F0272D": "Amazon", "44650D": "Amazon", "AC63BE": "Amazon",
"F0272D": "Amazon", "44650D": "Amazon", "AC63BE": "Amazon",
"34D270": "Amazon",
"001150": "Belkin", "001CDF": "Belkin", "0030BD": "Belkin",
"001150": "Belkin", "001CDF": "Belkin", "0030BD": "Belkin",
"94103E": "Belkin", "EC1A59": "Belkin",
"001E10": "Huawei", "002568": "Huawei", "28311C": "Huawei",
"3440B5": "Huawei", "4C1FCC": "Huawei", "6C8D37": "Huawei",
"001E10": "Huawei", "002568": "Huawei", "28311C": "Huawei",
"3440B5": "Huawei", "4C1FCC": "Huawei", "6C8D37": "Huawei",
"788C8A": "Huawei", "AC853D": "Huawei",
"0012F0": "Intel", "001320": "Intel", "0016EA": "Intel",
"0012F0": "Intel", "001320": "Intel", "0016EA": "Intel",
"001E64": "Intel", "002129": "Intel",
"000F66": "Qualcomm", "001374": "Qualcomm",
"000AF7": "Broadcom", "00904C": "Broadcom",
"000C43": "Ralink", "00E04C": "Ralink",
"4C5E0C": "MikroTik", "CC2DE0": "MikroTik", "E48D8C": "MikroTik",
"4C5E0C": "MikroTik", "CC2DE0": "MikroTik", "E48D8C": "MikroTik",
"B8690E": "MikroTik",
}
_oui_db: dict[str, str] = {}
_oui_lock: threading.Lock = threading.Lock()
def _parse_oui_csv(path: Path) -> dict[str, str]:
db: dict[str, str] = {}
with open(path, newline="", encoding="utf-8", errors="replace") as f:
for row in csv.reader(f):
if len(row) >= 3 and len(row[1]) == 6:
db[row[1].upper()] = row[2].strip()
return db
def _load_oui_db_worker() -> None:
global _oui_db
try:
needs_download = (
not _OUI_CACHE.exists()
or (time.time() - _OUI_CACHE.stat().st_mtime) > _OUI_MAX_AGE_DAYS * 86400
)
if needs_download:
_OUI_CACHE.parent.mkdir(parents=True, exist_ok=True)
urllib.request.urlretrieve(_OUI_URL, _OUI_CACHE)
db = _parse_oui_csv(_OUI_CACHE)
with _oui_lock:
_oui_db = db
except Exception:
pass
# Start loading in the background immediately so it's ready before the first scan
threading.Thread(target=_load_oui_db_worker, daemon=True).start()
def lookup_vendor(bssid: str) -> str:
oui = bssid.upper().replace(":", "").replace("-", "")[:6]
return _OUI_TABLE.get(oui, "-")
hexstr = bssid.upper().replace(":", "").replace("-", "")
# Locally-administered MAC (U/L bit, 0x02, set in the first octet): the OUI
# is randomized — not a real IEEE vendor assignment — so a lookup is
# meaningless. These are the virtual/guest/mesh BSSIDs a single radio spawns.
try:
if int(hexstr[:2], 16) & 0x02:
return "Local"
except ValueError:
pass
oui = hexstr[:6]
with _oui_lock:
db = _oui_db
return db.get(oui) or _OUI_BUILTIN.get(oui, "-")
@dataclass
@@ -99,7 +153,12 @@ class AccessPoint:
if not self.vendor or self.vendor == "-":
self.vendor = lookup_vendor(self.bssid)
if not self.band:
self.band = _band_from_channel(self.channel)
# Prefer freq-based detection — channel numbers overlap between bands
# (e.g. channel 37 exists in both 5 GHz and 6 GHz)
if self.freq_mhz:
self.band = _band_from_freq(self.freq_mhz)
else:
self.band = _band_from_channel(self.channel)
if not self.freq_mhz and self.channel:
self.freq_mhz = _freq_from_channel(self.channel)
@@ -186,6 +245,12 @@ def scan_averaged(samples: int = 10, delay: float = 0.1) -> list[AccessPoint]:
# ── Helpers ───────────────────────────────────────────────────────────────────
def _band_from_freq(freq_mhz: int) -> str:
if 2400 <= freq_mhz <= 2500: return "2.4 GHz"
if 5150 <= freq_mhz <= 5900: return "5 GHz"
if 5925 <= freq_mhz <= 7125: return "6 GHz"
return ""
def _band_from_channel(ch: int) -> str:
if 1 <= ch <= 14: return "2.4 GHz"
if 32 <= ch <= 177: return "5 GHz"
@@ -197,8 +262,200 @@ def _freq_from_channel(ch: int) -> int:
if 32 <= ch <= 177: return 5000 + ch * 5
return 0
def _security_from_ies(ie_bytes: bytes) -> str:
"""
Determine security by walking the raw beacon / probe-response IEs.
This is the only reliable source for HIDDEN networks: their security can't
be looked up by SSID via WlanGetAvailableNetworkList, but the RSN/WPA IEs
are present in the beacon regardless of whether the SSID is broadcast.
Element 48 (RSN) → WPA2 / WPA3, with AKM suite type telling us
PSK (Personal) vs SAE (WPA3) vs 802.1X (Enterprise).
Element 221 (Vendor, OUI
00:50:F2 type 1) → legacy WPA1.
Returns "" when no security IE is found, so the caller can fall back to the
Privacy capability bit to distinguish Open from "secured but unknown".
"""
has_rsn = has_wpa1 = has_psk = has_sae = is_ent = is_owe = False
i, n = 0, len(ie_bytes)
while i + 2 <= n:
eid = ie_bytes[i]
ln = ie_bytes[i + 1]
if i + 2 + ln > n:
break
body = ie_bytes[i + 2:i + 2 + ln]
if eid == 48 and ln >= 8: # RSN IE (WPA2 / WPA3)
has_rsn = True
# RSN body: version[2] group-cipher[4] pairwise-count[2] ...
# so the pairwise-cipher count starts at offset 6, the AKM list
# follows the pairwise list.
pairwise_count = int.from_bytes(body[6:8], "little")
akm_off = 8 + pairwise_count * 4
if akm_off + 2 <= len(body):
akm_count = int.from_bytes(body[akm_off:akm_off + 2], "little")
for k in range(akm_count):
s = akm_off + 2 + k * 4
if s + 4 <= len(body):
t = body[s + 3] # AKM suite selector type
if t in (1, 3, 5, 11, 12, 13): is_ent = True # 802.1X
elif t in (8, 9): has_sae = True # SAE/WPA3
elif t in (2, 4, 6): has_psk = True # PSK/WPA2
elif t == 18: is_owe = True # OWE
elif eid == 221 and ln >= 4 and bytes(body[:4]) == b"\x00\x50\xf2\x01":
has_wpa1 = True # WPA1 vendor IE
i += 2 + ln
if is_owe:
return "OWE (Enhanced Open)"
if is_ent:
return "WPA3-Enterprise" if has_sae else "WPA2-Enterprise"
if has_sae and has_psk:
return "WPA2/WPA3-Personal"
if has_sae:
return "WPA3-Personal"
if has_rsn:
return "WPA2-Personal"
if has_wpa1:
return "WPA-Personal"
return ""
def _mode_from_ies(ie_bytes: bytes, freq_mhz: int = 0) -> str:
"""
Determine the Wi-Fi generation by walking the raw 802.11 Information Elements
from a beacon / probe response.
This is the authoritative source for Wi-Fi 7 detection on Windows: the scan
list's dot11BssPhyType field caps at HE (10) and never reports EHT (11), so
802.11be can only be recognised from the EHT Capabilities element here.
Relevant elements (all carried inside Element ID 255 = Element ID Extension):
Ext ID 108 — EHT Capabilities → Wi-Fi 7 (802.11be)
Ext ID 35 — HE Capabilities → Wi-Fi 6 (802.11ax)
Ext ID 59 — HE 6 GHz Band Capabilities → confirms 6 GHz operation (6E)
Plus the classic elements:
Element 191 — VHT Capabilities → Wi-Fi 5 (802.11ac)
Element 45 — HT Capabilities → Wi-Fi 4 (802.11n)
"""
has_eht = has_he = has_he6 = has_vht = has_ht = False
i, n = 0, len(ie_bytes)
while i + 2 <= n:
eid = ie_bytes[i]
ln = ie_bytes[i + 1]
if i + 2 + ln > n:
break
body = ie_bytes[i + 2:i + 2 + ln]
if eid == 45:
has_ht = True
elif eid == 191:
has_vht = True
elif eid == 255 and ln >= 1: # Element ID Extension
ext = body[0]
if ext == 108: has_eht = True
elif ext == 35: has_he = True
elif ext == 59: has_he6 = True
i += 2 + ln
if has_eht:
return "be (Wi-Fi 7)"
if has_he:
if has_he6 or 5925 <= freq_mhz <= 7125:
return "ax (Wi-Fi 6E)"
return "ax (Wi-Fi 6)"
if has_vht:
return "ac (Wi-Fi 5)"
if has_ht:
return "n (Wi-Fi 4)"
return ""
def _vht_op_width(cw: int, seg0: int, seg1: int) -> str:
"""
Decode a VHT Operation 'Channel Width / CCFS0 / CCFS1' triple into a label.
cw: 0 = use HT (20/40), 1 = 80 MHz, 2 = 160 (deprecated), 3 = 80+80 (dep).
Modern APs signal 160/80+80 with cw=1 plus a non-zero CCFS1 segment, so the
gap between the two centre-frequency segment indices disambiguates them.
"""
if cw == 2:
return "160 MHz"
if cw == 3:
return "80+80 MHz"
if cw == 1:
if seg1:
d = abs(seg1 - seg0)
if d == 8: return "160 MHz"
if d > 16: return "80+80 MHz"
return "80 MHz"
return "" # cw == 0 → width is carried by the HT Operation element
def _width_from_ies(ie_bytes: bytes) -> str:
"""
Determine the operating channel width from the beacon's *Operation* elements.
The Windows scan list never exposes channel width, so it is parsed here. A
Wi-Fi 7 AP also carries HE/VHT/HT operation elements for backward compat,
so the newest-generation element present wins:
EHT Operation (Ext 106) → 20/40/80/160/320 (320 MHz is EHT-only)
HE Operation (Ext 36) → 6 GHz info: 20/40/80/160 (Wi-Fi 6E)
→ embedded VHT info (Wi-Fi 6 on 5 GHz)
VHT Operation (El 192) → 80/160/80+80 (Wi-Fi 5)
HT Operation (El 61) → 20/40 (Wi-Fi 4)
Field offsets follow the Linux kernel ieee80211.h layouts. All reads are
length-guarded slices, so a malformed IE yields "" rather than an error.
"""
eht = he = vht = ht = ""
i, n = 0, len(ie_bytes)
while i + 2 <= n:
eid = ie_bytes[i]
ln = ie_bytes[i + 1]
if i + 2 + ln > n:
break
body = ie_bytes[i + 2:i + 2 + ln]
if eid == 61 and ln >= 2: # HT Operation
sec = body[1] & 0x03 # secondary channel offset
wide = body[1] & 0x04 # STA channel width = "any"
ht = "40 MHz" if (wide and sec in (1, 3)) else "20 MHz"
elif eid == 192 and ln >= 3: # VHT Operation
vht = _vht_op_width(body[0], body[1], body[2])
elif eid == 255 and ln >= 1:
ext = body[0]
if ext == 36 and ln >= 7: # HE Operation
# he_oper_params[4] he_mcs_nss[2] then optional fields at off 7
params = int.from_bytes(body[1:5], "little")
off = 7
if params & 0x4000 and off + 3 <= ln: # VHT Op Info present
he = _vht_op_width(body[off], body[off + 1], body[off + 2])
off += 3
if params & 0x8000: # Max Co-Hosted BSSID
off += 1
if params & 0x20000 and off + 2 <= ln: # 6 GHz Op Info present
w = body[off + 1] & 0x03 # control byte, width bits
he = {0: "20 MHz", 1: "40 MHz",
2: "80 MHz", 3: "160 MHz"}.get(w, he)
elif ext == 106 and ln >= 7: # EHT Operation
# params[1] basic_mcs_nss[4] then EHT Op Info (control at body[6])
if body[1] & 0x01: # EHT Operation Info present
w = body[6] & 0x07 # 3-bit channel width
eht = {0: "20 MHz", 1: "40 MHz", 2: "80 MHz",
3: "160 MHz", 4: "320 MHz"}.get(w, "")
i += 2 + ln
return eht or he or vht or ht or ""
def _mode_from_flags(s: str) -> str:
u = s.upper()
if any(x in u for x in ("EHT", "802.11BE", "WIFI 7", "WI-FI 7")): return "be (Wi-Fi 7)"
if any(x in u for x in ("AX", "HE", "802.11AX", "WIFI 6", "WI-FI 6")): return "ax (Wi-Fi 6)"
if any(x in u for x in ("AC", "VHT", "802.11AC")): return "ac (Wi-Fi 5)"
if any(x in u for x in ("802.11N", " N ", "HT20", "HT40")): return "n (Wi-Fi 4)"
@@ -209,7 +466,7 @@ def _mode_from_flags(s: str) -> str:
def _channel_width_from_str(s: str) -> str:
s = s.strip()
for w in ("160", "80+80", "80", "40", "20"):
for w in ("320", "160", "80+80", "80", "40", "20"):
if w in s: return f"{w} MHz"
return s or "-"
@@ -309,13 +566,16 @@ def _wlanapi_enumerate_and_scan(wlan, handle) -> list[AccessPoint]:
("dot11BssPhyType", wt.DWORD),
("lRssi", ctypes.c_long), # ← raw hardware dBm
("uLinkQuality", wt.ULONG),
("bInRegDomain", wt.BOOL),
("bInRegDomain", ctypes.c_ubyte), # BOOLEAN = 1 byte
("usBeaconPeriod", wt.USHORT),
("ullTimestamp", ctypes.c_ulonglong),
("ullHostTimestamp", ctypes.c_ulonglong),
("usCapabilityInformation", wt.USHORT),
("ulChCenterFrequency", wt.ULONG), # kHz
("wlanRateSet", ctypes.c_ubyte * 16),
# WLAN_RATE_SET = { ULONG uRateSetLength; USHORT usRateSet[126]; } = 256 B.
# Was wrongly c_ubyte*16, which shifted ulIeOffset/ulIeSize by 240 bytes
# and made them read garbage (the cause of the earlier IE-read crash).
("wlanRateSet", ctypes.c_ubyte * 256),
("ulIeOffset", wt.ULONG),
("ulIeSize", wt.ULONG),
]
@@ -325,6 +585,43 @@ def _wlanapi_enumerate_and_scan(wlan, handle) -> list[AccessPoint]:
("dwNumberOfItems", wt.DWORD),
("wlanBssEntries", WLAN_BSS_ENTRY * 512)]
# ── WLAN_AVAILABLE_NETWORK — carries auth/cipher per SSID ────────────────
# dot11DefaultAuthAlgorithm values (DOT11_AUTH_ALGORITHM):
# 1=Open 2=WEP 3=WPA-Ent 4=WPA-PSK 5=WPA-None
# 6=WPA2-Ent(RSNA) 7=WPA2-PSK(RSNA)
# 8=WPA3-Ent 9=WPA3-SAE(Personal) 10=OWE 11=WPA3-Ent-192
class WLAN_AVAILABLE_NETWORK(ctypes.Structure):
_fields_ = [
("strProfileName", ctypes.c_wchar * 256),
("dot11Ssid", DOT11_SSID),
("dot11BssType", wt.DWORD),
("uNumberOfBssids", wt.ULONG),
("bNetworkConnectable", wt.BOOL),
("wlanNotConnectableReason", wt.DWORD),
("uNumberOfPhyTypes", wt.ULONG),
("dot11PhyTypes", wt.DWORD * 8),
("bMorePhyTypes", wt.BOOL),
("wlanSignalQuality", wt.ULONG),
("bSecurityEnabled", wt.BOOL),
("dot11DefaultAuthAlgorithm", wt.DWORD),
("dot11DefaultCipherAlgorithm", wt.DWORD),
("dwFlags", wt.DWORD),
("dwReserved", wt.DWORD),
]
class WLAN_AVAILABLE_NETWORK_LIST(ctypes.Structure):
_fields_ = [("dwNumberOfItems", wt.DWORD),
("dwIndex", wt.DWORD),
("Network", WLAN_AVAILABLE_NETWORK * 256)]
_AUTH_MAP = {
1: "Open", 2: "WEP (insecure)",
3: "WPA-Enterprise", 4: "WPA-Personal", 5: "WPA-Personal",
6: "WPA2-Enterprise", 7: "WPA2-Personal",
8: "WPA3-Enterprise", 9: "WPA3-Personal",
10: "OWE (Enhanced Open)", 11: "WPA3-Enterprise",
}
# ── Enumerate interfaces ──────────────────────────────────────────────────
iface_list_ptr = ctypes.POINTER(WLAN_INTERFACE_INFO_LIST)()
ret = wlan.WlanEnumInterfaces(handle, None, ctypes.byref(iface_list_ptr))
@@ -346,7 +643,28 @@ def _wlanapi_enumerate_and_scan(wlan, handle) -> list[AccessPoint]:
wlan.WlanScan(handle, ctypes.byref(iface_guid), None, None, None)
time.sleep(2.0) # allow the radio time to complete the scan sweep
# Retrieve BSS list
# Build SSID → security string from WlanGetAvailableNetworkList.
# This API returns DOT11_AUTH_ALGORITHM directly — no raw IE parsing needed.
ssid_security: dict[str, str] = {}
net_list_ptr = ctypes.POINTER(WLAN_AVAILABLE_NETWORK_LIST)()
if wlan.WlanGetAvailableNetworkList(
handle, ctypes.byref(iface_guid), 0, None, ctypes.byref(net_list_ptr)
) == 0:
net_list = net_list_ptr.contents
for k in range(net_list.dwNumberOfItems):
net = net_list.Network[k]
n_len = net.dot11Ssid.uSSIDLength
try:
n_ssid = bytes(net.dot11Ssid.ucSSID[:n_len]).decode(
"utf-8", errors="replace").strip("\x00")
except Exception:
n_ssid = ""
if n_ssid:
ssid_security[n_ssid] = _AUTH_MAP.get(
net.dot11DefaultAuthAlgorithm, "-")
wlan.WlanFreeMemory(net_list_ptr)
# Retrieve per-BSSID list (raw RSSI, phy type, frequency)
bss_list_ptr = ctypes.POINTER(WLAN_BSS_LIST)()
ret = wlan.WlanGetNetworkBssList(
handle, ctypes.byref(iface_guid),
@@ -361,6 +679,15 @@ def _wlanapi_enumerate_and_scan(wlan, handle) -> list[AccessPoint]:
bss_list = bss_list_ptr.contents
# Snapshot the whole BSS list buffer once, bounded by dwTotalSize.
# All IE access below slices this bytes object, so a bad offset yields
# wrong data — never an out-of-bounds read / crash.
list_base = ctypes.cast(bss_list_ptr, ctypes.c_void_p).value
try:
buf = ctypes.string_at(list_base, bss_list.dwTotalSize)
except Exception:
buf = b""
for j in range(bss_list.dwNumberOfItems):
entry = bss_list.wlanBssEntries[j]
@@ -387,21 +714,58 @@ def _wlanapi_enumerate_and_scan(wlan, handle) -> list[AccessPoint]:
# Map frequency to channel
channel = _channel_from_freq(freq_mhz)
# dot11BssPhyType → mode hint
# dot11BssPhyType → mode hint (fallback only — see IE override below).
# https://docs.microsoft.com/en-us/windows/win32/nativewifi/dot11-phy-type
phy_type = entry.dot11BssPhyType
mode = {
4: "a", # dot11_phy_type_ofdm (legacy 802.11a)
6: "g", # dot11_phy_type_erp
7: "a", # dot11_phy_type_ht (could also be n/5GHz)
8: "n (Wi-Fi 4)", # dot11_phy_type_ht
9: "ac (Wi-Fi 5)", # dot11_phy_type_vht
7: "n (Wi-Fi 4)", # dot11_phy_type_ht
8: "ac (Wi-Fi 5)", # dot11_phy_type_vht
9: "ad", # dot11_phy_type_dmg (802.11ad)
10: "ax (Wi-Fi 6)", # dot11_phy_type_he
11: "be (Wi-Fi 7)", # dot11_phy_type_eht (802.11be)
}.get(phy_type, "-")
# Wi-Fi 6E is 802.11ax (phy_type 10) operating in the 6 GHz band
if mode == "ax (Wi-Fi 6)" and 5925 <= freq_mhz <= 7125:
mode = "ax (Wi-Fi 6E)"
# Slice this BSS entry's beacon IEs from the bounded snapshot.
# A bad offset yields empty/wrong bytes — never an out-of-bounds read.
ie_bytes = b""
if buf:
ie_off = (ctypes.addressof(entry) - list_base) + entry.ulIeOffset
ie_end = ie_off + entry.ulIeSize
if 0 <= ie_off <= ie_end <= len(buf):
ie_bytes = buf[ie_off:ie_end]
# Authoritative mode from the beacon IEs. The scan list's phy type
# caps at HE (10) even for 802.11be, so EHT (Wi-Fi 7) is ONLY
# detectable from the EHT Capabilities element here.
if ie_bytes:
ie_mode = _mode_from_ies(ie_bytes, freq_mhz)
if ie_mode:
mode = ie_mode
# Security: prefer the SSID-keyed auth from WlanGetAvailableNetworkList.
# Hidden networks aren't in that list (no SSID to key on), so fall back
# to parsing the RSN/WPA IEs directly, then to the Privacy capability
# bit (0x10) to at least distinguish "Secured" from "Open".
security = ssid_security.get(ssid, "")
if not security and ie_bytes:
security = _security_from_ies(ie_bytes)
if not security:
privacy = bool(entry.usCapabilityInformation & 0x10)
security = "Secured" if privacy else "Open"
# Channel width — parsed from the HT/VHT/HE/EHT Operation elements,
# since the Windows scan list does not report it.
width = _width_from_ies(ie_bytes) if ie_bytes else ""
aps.append(AccessPoint(
ssid=ssid, bssid=bssid, signal_dbm=signal_dbm,
channel=channel, freq_mhz=freq_mhz,
mode=mode,
channel_width=width, mode=mode, security=security,
))
wlan.WlanFreeMemory(bss_list_ptr)
@@ -533,17 +897,21 @@ def _parse_iw_output(text: str) -> list[AccessPoint]:
else: sec = "Open"
# Mode from capability IEs
has_he = bool(re.search(r'HE capabilities', block, re.I))
has_eht = bool(re.search(r'EHT capabilities', block, re.I))
has_he = bool(re.search(r'HE capabilities', block, re.I))
has_vht = bool(re.search(r'VHT capabilities', block, re.I))
has_ht = bool(re.search(r'HT capabilities', block, re.I))
if has_he: mode = "ax (Wi-Fi 6)"
elif has_vht: mode = "ac (Wi-Fi 5)"
elif has_ht: mode = "n (Wi-Fi 4)"
elif freq_mhz and freq_mhz < 3000: mode = "g"
else: mode = "a"
if has_eht: mode = "be (Wi-Fi 7)"
elif has_he and freq_mhz and freq_mhz >= 5925: mode = "ax (Wi-Fi 6E)"
elif has_he: mode = "ax (Wi-Fi 6)"
elif has_vht: mode = "ac (Wi-Fi 5)"
elif has_ht: mode = "n (Wi-Fi 4)"
elif freq_mhz and freq_mhz < 3000: mode = "g"
else: mode = "a"
# Channel width
if re.search(r'channel width: 160', block): width = "160 MHz"
if re.search(r'channel width: 320', block): width = "320 MHz"
elif re.search(r'channel width: 160', block): width = "160 MHz"
elif re.search(r'channel width: 80', block): width = "80 MHz"
elif re.search(r'channel width: 40', block): width = "40 MHz"
else: width = "20 MHz"