10 Commits
6 changed files with 1109 additions and 51 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.
+22 -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 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 106 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,5,0),
prodvers=(2026,5,5,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.5.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.5.0')])
StringStruct(u'ProductVersion', u'2026.6.0.0')])
]),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
+30 -4
View File
@@ -322,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")
@@ -448,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")
@@ -479,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 ""
@@ -578,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:
+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"