38 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
iamdoubz 62c1b48093 Add verbage for new features 2026-05-04 03:45:47 -05:00
iamdoubz 95f8d1e2d2 Bump latest release version 2026-05-04 03:44:10 -05:00
iamdoubz 1833e412c7 Merge pull request '[Feature] Add ability to add AP to heatmap' (#14) from add_ap_positions into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/14
2026-05-04 03:40:50 -05:00
iamdoubz eccdbdcd78 Show AP positions on heatmap 2026-05-04 03:39:45 -05:00
iamdoubz 5c98e8e4a6 Add saving positions of AP to session file 2026-05-04 03:39:13 -05:00
iamdoubz 80c5781daa Add AP positioning to heatmap page 2026-05-04 03:38:18 -05:00
iamdoubz e48aa38b70 Bump latest release version 2026-05-04 03:24:28 -05:00
iamdoubz 10fde8278e Merge pull request 'Change logos' (#13) from change_logos into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/13
2026-05-04 03:22:05 -05:00
iamdoubz 060aa781b3 Change logos 2026-05-04 03:21:02 -05:00
iamdoubz f1e0a1838e Merge pull request 'Make heatmap more like ekahau' (#12) from visual_heatmap_filter into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/12
2026-05-04 02:42:51 -05:00
iamdoubz 24efe2802c Make heatmap more like ekahau 2026-05-04 02:41:34 -05:00
iamdoubz 34213ba4ee Merge pull request 'Fix SSID truncated' (#11) from fix_ssid_truncate into main
Reviewed-on: http://git.dou.bet/iamdoubz/pyfi-hmg/pulls/11
2026-05-04 02:33:58 -05:00
iamdoubz 988c40025e Display correct ssid 2026-05-04 02:30:48 -05:00
iamdoubz d4bdb340c8 Fix wrong windows sdk struct layout 2026-05-04 02:30:26 -05:00
iamdoubz f15ecc36e4 Change example export image 2026-05-01 16:55:22 -05:00
14 changed files with 1827 additions and 234 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.
+123 -39
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 📖
@@ -59,43 +64,48 @@ The application has two tabs and a persistent left sidebar.
<img src="extras/page-accesspoints.png" alt="An image of the wifi heatmap generator page 1 titled Access Points that shows discovered SSID and BSSID network information">
</p>
The first tab you see on launch. Shows a scrollable table of every access point
ever discovered during the session — including those that have gone out of range,
which remain in the list permanently and are shown dimmed with an "out of range"
indicator in the Level column.
The first tab you see on launch. Shows a scrollable table (horizontal and
vertical) of every access point ever discovered during the session — including
those that have gone out of range, which remain in the list permanently and are
shown dimmed with an "out of range" indicator in the Level column. Column
headers are pixel-exact aligned to their data columns.
**Table columns:**
| Column | Description |
|---|---|
| ✓ | Checkbox to include this BSSID in heatmap rendering |
| SSID | Network name |
| SSID | Network name (full name, never truncated) |
| BSSID | Hardware MAC address — unique per physical radio |
| Ch | 802.11 channel number |
| 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 |
| Min | Weakest signal ever recorded |
| Avg | Running average of all valid readings |
The Max, Min, and Avg columns accumulate across the entire session lifetime and
are cleared only when starting a new session. Max is tinted green, Min red, and
Avg is color-coded dynamically by signal quality.
**Toolbar controls:**
- **🔄 Refresh Scan** — Trigger a manual scan immediately.
- **☑ Select All / ☐ Deselect All** — Check or uncheck all rows for heatmap use.
- **Auto-scan** — Enable automatic periodic scanning. The interval is
user-configurable via a spinbox (minimum 3 seconds, default 10 seconds,
maximum 300 seconds). A live countdown shows time until the next scan.
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.
The table scrolls both horizontally and vertically. Column headers are
pixel-exact aligned to their data columns.
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
@@ -103,7 +113,14 @@ pixel-exact aligned to their data columns.
<img src="extras/page-heatmap.png" alt="An image of the wifi heatmap generator page 2 titled Heatmap showing a datagrid of measurements and active networks">
</p>
The heatmap canvas with measurement tools.
The heatmap canvas with measurement and AP placement tools.
**Toolbar controls:**
- **▶ Scan & Place** — Scan WiFi and enter placement mode; click your location on the canvas.
- **↩ Undo** — Remove the most recently recorded measurement point.
- **◆ Place APs** — Toggle AP placement mode (see AP Positions below).
- **✕ Clear AP Pins** — Remove all AP position pins from the session.
**Active Networks panel (right side):**
@@ -119,27 +136,51 @@ AP tab checkboxes are the sole selector.
A mode label shows either "Mode: single BSSID" or "Mode: averaging N BSSIDs".
**AP Positions panel (right side):**
Shows one row per active BSSID. Each row displays:
- A status indicator: ○ (not pinned), ◆ (pinned), or ▶ (armed for placement)
- The SSID and current pin coordinates (if placed)
- An ✕ remove button when pinned
To place an AP:
1. Click **◆ Place APs** in the toolbar to enter placement mode.
2. Click a network row in the AP Positions panel to arm it (▶ indicator appears).
3. Click the AP's physical location on the canvas — a ◆ diamond marker appears.
4. Repeat for each AP. Right-click an existing ◆ marker to remove it.
Placing APs is optional but improves heatmap accuracy — see *AP Positioning* below.
**Measurements panel (right side, full height):**
A scrollable list of every recorded measurement point showing its canvas
coordinates and how many APs were visible at that location. Individual
measurements can be deleted by selecting them and clicking 🗑 Delete Selected.
The Undo button removes the most recent point.
**Heatmap rendering:**
**Heatmap rendering (Ekahau-style):**
- The colormap runs **red** (strongest, ≥ -30 dBm) → orange → yellow → green →
teal → blue → **violet** (weakest, ≤ -90 dBm), matching professional site
survey tools. Strong signal zones are warm colors; weak zones are cool.
- The alpha channel is derived per-pixel from signal strength — strong zones are
opaque and weak/absent zones fade to transparent, revealing the floorplan
beneath. This produces the characteristic "bubble" zone appearance.
- A Gaussian blur is applied to zone edges so overlapping coverage areas blend
smoothly rather than showing hard triangulation boundaries.
- The heatmap is not drawn until at least **4 measurement points** are collected
for the selected BSSID(s). Until then a progress indicator shows how many
points have been collected and how many more are needed.
- Colored dots mark each valid measurement point, labeled with the dBm value.
for the selected BSSID(s). Until then a progress indicator shows how many more
are needed.
- **Colored dots** (circles) mark each valid measurement point, labeled with the dBm value.
- **Black dots with a `?`** mark measurement points where none of the selected
BSSIDs were visible — the user was there and scanned, but that radio had no
coverage at that location.
- The colorbar runs from black (no signal / not found) at the bottom through
navy → blue → cyan → green → yellow → orange → red (excellent) at the top.
A "No signal" label appears below the scale.
- **◆ Diamond markers** with WiFi arc icons mark AP physical positions when
pinned. These are visually distinct from measurement dots and show the SSID
label beneath the icon.
- The colorbar runs from black (no signal) at the bottom to red (excellent) at
the top. A "No signal" label appears below the scale.
- When no floorplan is loaded, a subtle coordinate grid is drawn instead, and
a warning note appears in the heatmap subtitle.
a warning note appears in the heatmap subtitle and export.
---
@@ -153,17 +194,34 @@ The Undo button removes the most recent point.
real spatial context and produces a significantly more accurate heatmap.
4. Switch to the **Heatmap** tab. The Active Networks panel shows your selected
BSSIDs and the current mode (single or averaged).
5. Walk to a location in your space and click **▶ Scan & Place**. The app
5. *(Optional)* Pin your AP locations using **◆ Place APs** — see *AP Positioning*.
6. Walk to a location in your space and click **▶ Scan & Place**. The app
scans WiFi (10 samples, 100 ms apart, median reported) and enters placement
mode. Click your current position on the canvas.
6. Repeat from step 5. Aim for **at least 1520 measurement points** for a
7. Repeat from step 6. Aim for **at least 1520 measurement points** for a
good interpolation. Prioritize:
- Locations very close to each router (expect -30 to -45 dBm)
- Far corners and edges of the space (expect -65 to -80 dBm)
- Both sides of walls and doorways
- Any spot you suspect has poor coverage
7. The heatmap auto-updates after each new point once the 4-point minimum is met.
8. Use **Export PNG** to save the result, or **Save Session** to continue later.
8. The heatmap auto-updates after each new point once the 4-point minimum is met.
9. Use **Export PNG** to save the result, or **Save Session** to continue later.
### AP Positioning
When you pin an AP's physical location on the canvas, the renderer injects a
synthetic anchor point at that position into the interpolation. The anchor value
is the strongest real measurement seen for that BSSID plus 5 dBm (capped at
-25 dBm), representing the expected near-field signal directly at the
transmitter.
Without an anchor, the interpolator estimates the signal peak from surrounding
measurement points, which can place it in the wrong location — especially in a
mesh system where measurement density may not be uniform. With an anchor, the
heatmap peak is correctly located at the physical AP, and coverage zones radiate
outward from the right origin.
AP positions are saved in the session file and restored on load.
### Mapping a mesh network accurately
@@ -178,6 +236,9 @@ multiple radios broadcast the same SSID:
checked, the multi-BSSID averaging mode will produce a combined coverage map.
- For the most accurate single-radio map, consider temporarily disabling the
other nodes in your mesh system's admin panel during the session.
- Pinning each node's physical location with **◆ Place APs** is especially
useful for mesh systems — it anchors each radio's peak independently so the
averaged multi-BSSID map correctly reflects all three coverage zones.
---
@@ -206,6 +267,19 @@ This can reveal 815 dBm of real variation that `netsh` completely hides.
`WlanScan()` is also called first to request a fresh radio sweep rather than
reading the OS scan cache (which can be 3060 seconds stale).
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
@@ -213,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`
@@ -260,7 +334,9 @@ per BSSID across the entire session.
## Interpolation
Signal values between measurement points are interpolated using scipy:
Signal values between measurement points are interpolated using scipy. When AP
positions are pinned, synthetic anchor points are injected at the transmitter
locations before interpolation runs.
| Points collected | Method used |
|---|---|
@@ -281,8 +357,8 @@ entire canvas is always covered.
|---|---|---|
| -30 to -50 | Excellent | Red → Orange → Yellow |
| -50 to -65 | Good | Yellow → Green |
| -65 to -75 | Fair | Green → Cyan |
| -75 to -90 | Poor | Cyan → Blue → Navy |
| -65 to -75 | Fair | Green → Teal |
| -75 to -90 | Poor | Teal → Blue → Violet |
| Not found | No signal | Black dot (●) on map |
---
@@ -290,15 +366,17 @@ entire canvas is always covered.
## Session Files
Sessions are saved as `.json` files containing all measurement points, signal
readings for every visible BSSID at each point, SSID mappings, the floorplan
path, and canvas dimensions. Sessions can be loaded at startup:
readings for every visible BSSID at each point, SSID mappings, AP position
pins, the floorplan path, and canvas dimensions. Sessions can be loaded at startup:
```bash
python main.py --session my_office.json
```
Measurements can be deleted individually from the Heatmap tab. The session is
not auto-saved — use **💾 Save Session** or **💾 Save As…** from the sidebar.
Measurements can be deleted individually from the Heatmap tab. AP pins can be
removed individually from the AP Positions panel or all at once via
**✕ Clear AP Pins**. The session is not auto-saved — use **💾 Save Session**
or **💾 Save As…** from the sidebar.
---
@@ -318,17 +396,23 @@ not auto-saved — use **💾 Save Session** or **💾 Save As…** from the sid
| File | Purpose |
|---|---|
| `main.py` | Tkinter GUI — two-tab layout (Access Points + Heatmap), sidebar, auto-scan engine, AP stats tracking |
| `scanner.py` | Cross-platform WiFi scanning with `wlanapi` / `iw` / `nmcli` / `airport` backends, median averaging |
| `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, 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` | Matplotlib heatmap rendering — colorbar, measurement dots, missing-signal black dots |
| `data.py` | Session model — measurements, multi-BSSID averaging, missing-point detection, JSON persistence |
| `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 |
| `requirements.txt` | Python dependencies (`numpy`, `scipy`, `matplotlib`, `Pillow`) |
---
## 🖼️ 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>
+35
View File
@@ -30,6 +30,10 @@ class Session:
canvas_width: int = 800
canvas_height: int = 600
measurements: list[Measurement] = field(default_factory=list)
# Physical positions of access points on the canvas, keyed by BSSID.
# Optional — when set, the renderer uses these as anchor points to pin the
# interpolation peak to the real transmitter location.
ap_positions: dict[str, tuple[float, float]] = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
@@ -106,6 +110,32 @@ class Session:
missing.append((m.x, m.y))
return missing
def get_anchor_points(self, bssids: list[str]) -> tuple[list, list]:
"""
Return synthetic (x, y) anchor points and estimated dBm values for any
BSSID in `bssids` that has a known physical position in ap_positions.
The anchor value is the strongest real measurement seen for that BSSID
plus 5 dBm (capped at -25 dBm), representing the expected near-field
signal directly at the transmitter. These points are injected into the
interpolation so the heatmap peak is correctly anchored to the AP's
physical location rather than estimated from surrounding measurements.
"""
points, values = [], []
for bssid in bssids:
if bssid not in self.ap_positions:
continue
pos = self.ap_positions[bssid]
# Find the strongest real reading for this BSSID
real_pts, real_vals = self.get_points_and_values(bssid)
if real_vals:
anchor_dbm = min(-25, max(real_vals) + 5)
else:
anchor_dbm = -35 # reasonable default when no measurements exist yet
points.append(pos)
values.append(float(anchor_dbm))
return points, values
def save(self, path: str):
"""Save session to JSON file."""
data = {
@@ -115,6 +145,8 @@ class Session:
"canvas_height": self.canvas_height,
"created_at": self.created_at,
"updated_at": datetime.now().isoformat(),
# Serialize ap_positions as {bssid: [x, y]} for JSON compatibility
"ap_positions": {b: list(pos) for b, pos in self.ap_positions.items()},
"measurements": [
{
"x": m.x,
@@ -143,6 +175,9 @@ class Session:
created_at=data.get("created_at", ""),
updated_at=data.get("updated_at", "")
)
# Restore AP positions — stored as {bssid: [x, y]}, convert to tuples
for bssid, pos in data.get("ap_positions", {}).items():
session.ap_positions[bssid] = (float(pos[0]), float(pos[1]))
for m in data.get("measurements", []):
session.measurements.append(Measurement(
x=m["x"],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 334 KiB

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.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 929 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,1,0),
prodvers=(2026,5,1,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.1.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.1.0')])
StringStruct(u'ProductVersion', u'2026.6.0.0')])
]),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
+339 -35
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)
@@ -127,6 +141,11 @@ class WiFiHeatmapApp:
self._auto_scan_job: Optional[str] = None # root.after() handle
self._auto_scan_enabled: bool = False
# AP position drag state
self._ap_drag_mode: bool = False # True when "Place APs" is active
self._dragging_bssid: Optional[str] = None
self._drag_ghost = None # matplotlib artist for drag preview
self._build_ui()
self._apply_styles()
@@ -178,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)
@@ -291,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")
@@ -417,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")
@@ -448,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 ""
@@ -460,7 +513,7 @@ class WiFiHeatmapApp:
text_fg = TEXT if is_live else dim_fg
dim2 = TEXT_DIM if is_live else dim_fg
_cell(ssid[:20], 160, fg=text_fg, bold=True)
_cell(ssid, 160, fg=text_fg, bold=True)
_cell(bssid, 145, fg=dim2)
_cell(channel, 38, fg=text_fg)
_cell(freq, 90, fg=text_fg)
@@ -547,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:
@@ -597,6 +654,20 @@ class WiFiHeatmapApp:
command=self._undo_last)
self.undo_btn.grid(row=0, column=2, padx=4, pady=8)
# Place APs toggle — activates drag-and-drop mode for positioning APs
self.place_ap_btn = tk.Button(toolbar, text="◆ Place APs",
font=("Courier", 9), bg=BG2, fg=ACCENT2,
relief="flat", padx=10, pady=6, cursor="hand2",
activebackground=BG3,
command=self._toggle_ap_drag_mode)
self.place_ap_btn.grid(row=0, column=3, padx=4, pady=8)
self.clear_ap_pos_btn = tk.Button(toolbar, text="✕ Clear AP Pins",
font=("Courier", 9), bg=BG2, fg=TEXT_DIM,
relief="flat", padx=10, pady=6, cursor="hand2",
command=self._clear_ap_positions)
self.clear_ap_pos_btn.grid(row=0, column=4, padx=4, pady=8)
self.status_label = tk.Label(toolbar, text="Ready",
font=("Courier", 9), bg=BG3, fg=TEXT_DIM)
self.status_label.grid(row=0, column=99, padx=12, sticky="e")
@@ -609,7 +680,9 @@ class WiFiHeatmapApp:
self.canvas_widget = FigureCanvasTkAgg(self.fig, master=f)
self.canvas_widget.get_tk_widget().grid(row=1, column=0, sticky="nsew")
self.canvas_widget.mpl_connect("button_press_event", self._on_canvas_click)
self.canvas_widget.mpl_connect("button_press_event", self._on_canvas_click)
self.canvas_widget.mpl_connect("motion_notify_event", self._on_canvas_motion)
self.canvas_widget.mpl_connect("button_release_event", self._on_canvas_release)
f.columnconfigure(0, weight=1)
# Right panel — AP selector + measurements (full window height)
@@ -631,7 +704,8 @@ class WiFiHeatmapApp:
tk.Label(f,
text="Check networks on the Access\nPoints tab to include them here.\n"
"1 selected = single BSSID mode.\n"
"2+ selected = averaged mode.",
"2+ selected = averaged mode.\n"
"Enable ◆ Place APs then drag\na network here onto the map.",
font=("Courier", 7), bg=BG2, fg=TEXT_DIM, justify="left", wraplength=230
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 6))
@@ -663,19 +737,36 @@ class WiFiHeatmapApp:
ttk.Separator(f, orient="horizontal").grid(row=4, column=0, sticky="ew", padx=8, pady=6)
# ── AP Positions panel ─────────────────────────────────────────────────
tk.Label(f, text="AP POSITIONS ◆", font=("Courier", 8, "bold"),
bg=BG2, fg=TEXT_DIM).grid(row=5, column=0, sticky="w", padx=14, pady=(4, 2))
tk.Label(f,
text="Click ◆ Place APs, then click a\nnetwork below to arm it, then\n"
"click its location on the map.\nRight-click a ◆ marker to remove.",
font=("Courier", 7), bg=BG2, fg=TEXT_DIM, justify="left", wraplength=230
).grid(row=6, column=0, sticky="w", padx=14, pady=(0, 4))
# Frame that holds one row per active BSSID
self.ap_pos_frame = tk.Frame(f, bg=BG2)
self.ap_pos_frame.grid(row=7, column=0, sticky="ew", padx=8, pady=(0, 4))
self.ap_pos_frame.columnconfigure(0, weight=1)
ttk.Separator(f, orient="horizontal").grid(row=8, column=0, sticky="ew", padx=8, pady=6)
# ── Measurements list (full remaining height) ──────────────────────
tk.Label(f, text="MEASUREMENTS", font=("Courier", 8, "bold"),
bg=BG2, fg=TEXT_DIM).grid(row=5, column=0, sticky="w", padx=14, pady=(4, 2))
bg=BG2, fg=TEXT_DIM).grid(row=9, column=0, sticky="w", padx=14, pady=(4, 2))
self.meas_count_label = tk.Label(f, text="0 points collected",
font=("Courier", 8), bg=BG2, fg=ACCENT2)
self.meas_count_label.grid(row=6, column=0, sticky="w", padx=14, pady=(0, 4))
self.meas_count_label.grid(row=10, column=0, sticky="w", padx=14, pady=(0, 4))
meas_frame = tk.Frame(f, bg=BG2)
meas_frame.grid(row=7, column=0, sticky="nsew", padx=8, pady=(0, 4))
meas_frame.grid(row=11, column=0, sticky="nsew", padx=8, pady=(0, 4))
meas_frame.columnconfigure(0, weight=1)
meas_frame.rowconfigure(0, weight=1)
f.rowconfigure(7, weight=1)
f.rowconfigure(11, weight=1)
meas_scroll = ttk.Scrollbar(meas_frame, orient="vertical")
meas_scroll.grid(row=0, column=1, sticky="ns")
@@ -694,7 +785,7 @@ class WiFiHeatmapApp:
font=("Courier", 8), bg=BG3, fg=DANGER,
relief="flat", padx=8, pady=4, cursor="hand2",
command=self._delete_measurement)
self.del_meas_btn.grid(row=8, column=0, sticky="w", padx=12, pady=(0, 10))
self.del_meas_btn.grid(row=12, column=0, sticky="w", padx=12, pady=(0, 10))
# ── Active BSSID display on Heatmap tab ───────────────────────────────────
@@ -723,7 +814,89 @@ class WiFiHeatmapApp:
self._redraw()
# ── Scanning (AP Tab) ─────────────────────────────────────────────────────
def _update_ap_positions_panel(self, highlight: Optional[str] = None):
"""
Rebuild the AP Positions panel rows — one row per active BSSID.
Each row shows: armed indicator | SSID | pinned coords | remove button.
Clicking the row arms that BSSID for placement.
"""
for w in self.ap_pos_frame.winfo_children():
w.destroy()
bssids = self.selected_bssids_for_heatmap
if not bssids:
tk.Label(self.ap_pos_frame, text=" No networks selected",
font=("Courier", 7), bg=BG2, fg=TEXT_DIM
).grid(row=0, column=0, sticky="w", padx=8, pady=2)
return
for i, bssid in enumerate(bssids):
ssid = self.session.get_ssid(bssid)
pos = self.session.ap_positions.get(bssid)
is_armed = (bssid == self._dragging_bssid and self._ap_drag_mode)
is_pinned = pos is not None
row_bg = "#1a2a1a" if is_armed else (BG3 if is_pinned else BG2)
row = tk.Frame(self.ap_pos_frame, bg=row_bg,
highlightthickness=1,
highlightbackground=ACCENT2 if is_armed else BORDER)
row.grid(row=i, column=0, sticky="ew", pady=1)
row.columnconfigure(1, weight=1)
# Armed indicator
armed_lbl = tk.Label(row,
text="" if is_armed else ("" if is_pinned else ""),
font=("Courier", 8, "bold"), bg=row_bg,
fg=ACCENT2 if is_armed else (SUCCESS if is_pinned else TEXT_DIM),
padx=4)
armed_lbl.grid(row=0, column=0, sticky="w")
# SSID + coords
coord_str = f" ({int(pos[0])}, {int(pos[1])})" if pos else " not pinned"
name_lbl = tk.Label(row,
text=f"{ssid[:14]}{coord_str}",
font=("Courier", 7), bg=row_bg,
fg=TEXT if is_pinned else TEXT_DIM,
anchor="w", padx=2)
name_lbl.grid(row=0, column=1, sticky="ew")
# Remove button (only shown when pinned)
if is_pinned:
rm_btn = tk.Button(row, text="",
font=("Courier", 7), bg=row_bg, fg=DANGER,
relief="flat", padx=2, cursor="hand2",
command=lambda b=bssid: self._remove_ap_pin(b))
rm_btn.grid(row=0, column=2, sticky="e", padx=2)
# Click anywhere on the row to arm this BSSID
for widget in (row, armed_lbl, name_lbl):
widget.bind("<Button-1>",
lambda e, b=bssid: self._select_ap_for_pin(b))
def _select_ap_for_pin(self, bssid: str):
"""Arm a BSSID for placement — next canvas click drops its pin there."""
self._dragging_bssid = bssid
if not self._ap_drag_mode:
self._ap_drag_mode = True
self.place_ap_btn.config(bg=ACCENT2, fg='black',
text="◆ Placing APs (click to cancel)")
ssid = self.session.get_ssid(bssid)
self._set_status(
f"Click the location of {ssid} on the map. "
"Right-click an existing ◆ to remove it.")
self.canvas_widget.get_tk_widget().config(cursor="crosshair")
self._update_ap_positions_panel(highlight=bssid)
def _remove_ap_pin(self, bssid: str):
"""Remove a single AP pin."""
if bssid in self.session.ap_positions:
self.session.ap_positions.pop(bssid)
if self._dragging_bssid == bssid:
self._dragging_bssid = None
ssid = self.session.get_ssid(bssid)
self._set_status(f"◆ Removed pin for {ssid}")
self._update_ap_positions_panel()
self._redraw()
def _refresh_scan(self):
if self.scanning: return
@@ -866,12 +1039,90 @@ class WiFiHeatmapApp:
self._set_status("✓ Scan done — click your position on the map")
self.canvas_widget.get_tk_widget().config(cursor="crosshair")
def _undo_last(self):
if not self.session.measurements: return
self.session.remove_measurement(len(self.session.measurements) - 1)
self._set_status("Last measurement removed")
self._refresh_everything()
def _delete_measurement(self):
sel = self.meas_listbox.curselection()
if not sel: return
idx = sel[0]
self.session.remove_measurement(idx)
self._refresh_everything()
self._set_status(f"Measurement {idx + 1} deleted")
# ── AP position drag-and-drop ──────────────────────────────────────────────
def _toggle_ap_drag_mode(self):
"""Toggle AP placement mode on/off."""
self._ap_drag_mode = not self._ap_drag_mode
if self._ap_drag_mode:
self.place_ap_btn.config(bg=ACCENT2, fg='black')
self._set_status(
"◆ Place APs mode — click an AP in the list, then click its location on the map. "
"Right-click an existing pin to remove it.")
self.canvas_widget.get_tk_widget().config(cursor="crosshair")
# Highlight the active_bssid_listbox so user knows to click there first
self.active_bssid_listbox.config(highlightthickness=2,
highlightcolor=ACCENT2,
highlightbackground=ACCENT2)
else:
self._ap_drag_mode = False
self._dragging_bssid = None
self.place_ap_btn.config(bg=BG2, fg=ACCENT2)
self._set_status("Ready")
self.canvas_widget.get_tk_widget().config(cursor="")
self.active_bssid_listbox.config(highlightthickness=0)
def _get_selected_listbox_bssid(self) -> Optional[str]:
"""Return the BSSID currently selected in the active networks listbox."""
sel = self.active_bssid_listbox.curselection()
if not sel:
return None
idx = sel[0]
bssids = self.selected_bssids_for_heatmap
if idx < len(bssids):
return bssids[idx]
return None
def _on_canvas_click(self, event):
"""Handles both placement-mode measurement recording and AP pin dropping."""
if event.inaxes != self.ax: return
if not self._placement_mode or not self._pending_scan: return
x, y = event.xdata, event.ydata
if x is None or y is None: return
# ── Right-click: remove AP pin under cursor ────────────────────────────
if event.button == 3 and self._ap_drag_mode:
hit_radius = max(self.session.canvas_width,
self.session.canvas_height) * 0.025
for bssid, (px, py) in list(self.session.ap_positions.items()):
if abs(px - x) < hit_radius and abs(py - y) < hit_radius:
del self.session.ap_positions[bssid]
ssid = self.session.get_ssid(bssid)
self._set_status(f"Removed pin for {ssid}")
self._redraw()
return
return
# ── AP drag mode: left-click drops the selected AP ─────────────────────
if self._ap_drag_mode and event.button == 1:
bssid = self._dragging_bssid
if not bssid:
self._set_status("⚠ Click a network in the AP Positions panel first, then click its location.")
return
self.session.ap_positions[bssid] = (x, y)
ssid = self.session.get_ssid(bssid)
self._dragging_bssid = None
self._set_status(f"◆ Pinned {ssid} at ({int(x)}, {int(y)}) — right-click pin to remove")
self._update_ap_positions_panel()
self._redraw()
return
# ── Normal measurement placement ───────────────────────────────────────
if not self._placement_mode or not self._pending_scan: return
signals = {ap.bssid: ap.signal_dbm for ap in self._pending_scan}
ssid_map = {ap.bssid: ap.ssid for ap in self._pending_scan}
@@ -886,19 +1137,61 @@ class WiFiHeatmapApp:
self._set_status(f"✓ Point #{n} recorded at ({int(x)}, {int(y)})")
self._refresh_everything()
def _undo_last(self):
if not self.session.measurements: return
self.session.remove_measurement(len(self.session.measurements) - 1)
self._set_status("Last measurement removed")
self._refresh_everything()
def _on_canvas_motion(self, event):
"""Show a ghost diamond following the cursor in AP drag mode."""
if not self._ap_drag_mode: return
if event.inaxes != self.ax: return
if event.xdata is None or event.ydata is None: return
def _delete_measurement(self):
sel = self.meas_listbox.curselection()
if not sel: return
idx = sel[0]
self.session.remove_measurement(idx)
self._refresh_everything()
self._set_status(f"Measurement {idx + 1} deleted")
bssid = self._dragging_bssid
if not bssid: return
# Draw a ghost diamond at cursor position — redraw is expensive so we
# use blit-safe artists: remove the old ghost and add a new one.
if self._drag_ghost:
try:
self._drag_ghost.remove()
except Exception:
pass
self._drag_ghost = None
import matplotlib.patches as mpatches
import matplotlib.path as mpath
w = self.session.canvas_width
h = self.session.canvas_height
r = max(w, h) * 0.012 * 1.6
px, py = event.xdata, event.ydata
verts = [(px, py-r), (px+r, py), (px, py+r), (px-r, py), (px, py-r)]
codes = [mpath.Path.MOVETO, mpath.Path.LINETO, mpath.Path.LINETO,
mpath.Path.LINETO, mpath.Path.CLOSEPOLY]
self._drag_ghost = mpatches.PathPatch(
mpath.Path(verts, codes),
facecolor=ACCENT2, edgecolor='white',
linewidth=1.5, zorder=10, alpha=0.6
)
self.ax.add_patch(self._drag_ghost)
self.canvas_widget.draw_idle()
def _on_canvas_release(self, event):
"""Clean up ghost on mouse release (no action needed — drop is on click)."""
if self._drag_ghost:
try:
self._drag_ghost.remove()
except Exception:
pass
self._drag_ghost = None
if self._ap_drag_mode:
self.canvas_widget.draw_idle()
def _clear_ap_positions(self):
"""Remove all AP position pins from the session."""
if not self.session.ap_positions: return
if messagebox.askyesno("Clear AP Pins", "Remove all AP position pins?"):
self.session.ap_positions.clear()
self._set_status("All AP pins cleared")
self._redraw()
# ── Rendering ─────────────────────────────────────────────────────────────
@@ -962,6 +1255,9 @@ class WiFiHeatmapApp:
self.session_path = None
self.selected_bssids_for_heatmap = []
self._ap_stats.clear()
self._ap_drag_mode = False
self._dragging_bssid = None
self.place_ap_btn.config(bg=BG2, fg=ACCENT2)
self._refresh_everything()
self._set_status("New session started")
@@ -1038,6 +1334,7 @@ class WiFiHeatmapApp:
self._update_session_info()
self._update_floorplan_label()
self._update_heatmap_ap_selector()
self._update_ap_positions_panel()
self._redraw()
def _update_measurements_list(self):
@@ -1085,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()))
+261 -114
View File
@@ -1,34 +1,48 @@
"""
renderer.py — Renders WiFi heatmaps using matplotlib.
Supports both floorplan overlay and grid-only mode.
Ekahau-style rendering:
- Colormap runs red (strongest) → orange → yellow → green → teal → blue →
violet (weakest), matching professional site survey tools.
- Alpha channel is derived per-pixel from signal strength so strong-signal
zones are opaque and weak/absent zones fade to transparent, revealing the
floorplan or grid beneath. This produces the "bubble" zone appearance.
- A Gaussian smoothing pass is applied to the RGBA image to soften the
zone edges and blend overlapping coverage areas naturally.
"""
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
from scipy.ndimage import gaussian_filter
from typing import Optional
from PIL import Image
from data import Session, Measurement
from interpolator import interpolate, HeatmapData, get_signal_at
from data import Session
from interpolator import interpolate, HeatmapData
# Custom colormap: black (no signal) → dark navy → blue → cyan → green → yellow → red (strong)
# Black occupies the very bottom so "not found" markers read naturally against the scale.
SIGNAL_COLORS = [
(0.00, 0.00, 0.00), # no signal: black ← new
(0.05, 0.05, 0.35), # very weak: dark navy
(0.10, 0.40, 0.80), # weak: blue
(0.10, 0.80, 0.80), # moderate: cyan
(0.20, 0.90, 0.20), # good: green
(1.00, 0.90, 0.10), # strong: yellow
(1.00, 0.40, 0.00), # very strong: orange
(0.90, 0.05, 0.05), # excellent: red
# ── Colormaps ─────────────────────────────────────────────────────────────────
_EKAHAU_COLORS = [
(0.45, 0.00, 0.55), # -90 dBm violet / purple
(0.10, 0.10, 0.80), # -80 dBm blue
(0.00, 0.60, 0.80), # -70 dBm teal
(0.10, 0.80, 0.30), # -65 dBm green
(0.70, 0.95, 0.10), # -58 dBm yellow-green
(1.00, 0.85, 0.00), # -52 dBm yellow
(1.00, 0.50, 0.00), # -46 dBm orange
(0.95, 0.10, 0.10), # -30 dBm red
]
SIGNAL_CMAP = mcolors.LinearSegmentedColormap.from_list("wifi_signal", SIGNAL_COLORS)
SIGNAL_CMAP = mcolors.LinearSegmentedColormap.from_list("ekahau", _EKAHAU_COLORS)
VMIN, VMAX = -90, -30
def render_heatmap(
@@ -36,163 +50,143 @@ def render_heatmap(
bssids: list[str],
fig: Optional[Figure] = None,
ax: Optional[Axes] = None,
alpha: float = 0.65,
alpha: float = 0.85,
show_points: bool = True,
show_colorbar: bool = True,
export_path: Optional[str] = None,
) -> tuple[Figure, Axes, Optional[HeatmapData]]:
"""
Render a heatmap for one or more BSSIDs onto a matplotlib figure.
Render an Ekahau-style heatmap for one or more BSSIDs.
Args:
session: The data session with measurements.
bssids: List of BSSIDs to visualize. If more than one, signal
values are averaged per measurement point (only BSSIDs
actually visible at each point contribute to the average).
fig/ax: Existing figure/axes to draw onto (creates new if None).
alpha: Transparency of the heatmap overlay.
show_points: Whether to draw measurement dot markers.
show_colorbar: Whether to draw the dBm color scale.
export_path: If set, saves figure to this path.
The heatmap is composited as an RGBA image where:
- RGB = signal strength mapped through the Ekahau colormap
- Alpha = derived from signal strength so strong zones are opaque and
weak/absent zones fade to transparent, revealing the background.
Returns:
(figure, axes, heatmap_data)
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.
"""
if not bssids:
raise ValueError("At least one BSSID must be provided.")
if fig is None or ax is None:
fig, ax = plt.subplots(figsize=(10, 7))
fig.subplots_adjust(right=0.88) # permanent right margin for the colorbar
fig.subplots_adjust(right=0.88)
# Remove any axes that aren't the main plot (stale colorbars, etc.).
# We then recreate a fixed-position colorbar axes below so matplotlib never
# steals space from ax — that is what caused ax to shrink on every redraw.
# Remove stale colorbar axes from previous renders
for ax_obj in fig.axes[1:]:
ax_obj.remove()
ax.clear()
points, values = session.get_points_and_values_multi(bssids)
missing_points = session.get_missing_points(bssids) # positions with no signal
w, h = session.canvas_width, session.canvas_height
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
# Build a human-readable label for the title
interp_points = points + anchor_pts
interp_values = values + anchor_vals
# ── Title label ───────────────────────────────────────────────────────────
if len(bssids) == 1:
ssid = session.get_ssid(bssids[0])
ssid = session.get_ssid(bssids[0])
ap_label = f"{ssid}{bssids[0]}"
else:
# Show the shared SSID if all BSSIDs share one, otherwise list count
ssids = list({session.get_ssid(b) for b in bssids})
ssid = ssids[0] if len(ssids) == 1 else f"{len(ssids)} networks"
ssids = list({session.get_ssid(b) for b in bssids})
ssid = ssids[0] if len(ssids) == 1 else f"{len(ssids)} networks"
ap_label = f"{ssid}{len(bssids)} BSSIDs averaged"
# ── Draw background ───────────────────────────────────────────────────────
# ── Background ────────────────────────────────────────────────────────────
ax.set_facecolor('#1a1a1a')
fig.patch.set_facecolor('#0d0d1a')
if session.floorplan_path:
try:
img = Image.open(session.floorplan_path).convert("RGBA")
img = img.resize((w, h), Image.LANCZOS)
ax.imshow(np.array(img), extent=[0, w, h, 0], aspect='auto', zorder=1)
fp_img = Image.open(session.floorplan_path).convert("RGBA")
fp_img = fp_img.resize((w, h), Image.LANCZOS)
ax.imshow(np.array(fp_img), extent=[0, w, h, 0],
aspect='auto', zorder=1)
except Exception as e:
_draw_grid_background(ax, w, h)
print(f"[renderer] Could not load floorplan: {e}")
else:
_draw_grid_background(ax, w, h)
# ── Heatmap ───────────────────────────────────────────────────────────────
heatmap_data = None
MIN_POINTS = 4 # must match interpolator.MIN_POINTS_FOR_INTERPOLATION
MIN_POINTS = 4
if len(points) >= MIN_POINTS:
# ── Interpolate & draw heatmap ─────────────────────────────────────
heatmap_data = interpolate(points, values, w, h)
img_heatmap = ax.imshow(
heatmap_data.grid_z,
extent=[0, w, 0, h],
origin='upper',
cmap=SIGNAL_CMAP,
vmin=-90,
vmax=-30,
alpha=alpha,
zorder=2,
aspect='auto'
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)
ax.imshow(
rgba,
extent=[0, w, 0, h], origin='upper',
aspect='auto', zorder=2, interpolation='bilinear'
)
if show_colorbar:
# Add a fixed-position axes for the colorbar. Using cax= instead of
# ax= means matplotlib draws into this dedicated axes without
# touching ax at all — no space is stolen, ax never shrinks.
cax = fig.add_axes([0.91, 0.15, 0.02, 0.70]) # [left, bottom, width, height]
cbar = fig.colorbar(img_heatmap, cax=cax)
cbar.set_label("Signal Strength (dBm)", fontsize=10)
cbar.ax.tick_params(labelsize=8)
cbar.ax.text(1.4, -90, "Poor", transform=cbar.ax.get_yaxis_transform(),
fontsize=7, color='gray', va='bottom')
cbar.ax.text(1.4, -30, "Excellent", transform=cbar.ax.get_yaxis_transform(),
fontsize=7, color='gray', va='top')
cbar.ax.text(1.4, -95, "No signal", transform=cbar.ax.get_yaxis_transform(),
fontsize=7, color='black', va='top',
bbox=dict(facecolor='white', edgecolor='none', pad=1.5))
else:
# ── Not enough points yet — show progress indicator ────────────────
collected = len(points)
remaining = MIN_POINTS - collected
filled = "" * collected
empty = "" * remaining
bar = f"[{filled}{empty}] {collected}/{MIN_POINTS}"
_draw_colorbar(fig)
msg = (
f"Collecting data\n\n"
f"{bar}\n\n"
f"Add {remaining} more point{'s' if remaining != 1 else ''} to generate the heatmap.\n"
f"Measurement dots are shown below."
)
else:
collected = len(interp_points)
remaining = MIN_POINTS - collected
bar = f"[{'' * collected}{'' * remaining}] {collected}/{MIN_POINTS}"
msg = (f"Collecting data\n\n{bar}\n\n"
f"Add {remaining} more point{'s' if remaining != 1 else ''} "
f"to generate the heatmap.")
ax.text(w / 2, h / 2, msg,
ha='center', va='center', fontsize=12,
color='#8888bb', linespacing=1.9,
fontfamily='monospace',
bbox=dict(boxstyle='round,pad=0.8', facecolor='#10101e', alpha=0.8),
color='#8888bb', linespacing=1.9, fontfamily='monospace',
bbox=dict(boxstyle='round,pad=0.8',
facecolor='#10101e', alpha=0.8),
zorder=3)
# ── Draw measurement points ───────────────────────────────────────────────
# ── Measurement dots ──────────────────────────────────────────────────────
radius = max(w, h) * 0.012
if show_points:
# Valid signal points — colored by strength
for (px, py), dbm in zip(points, values):
color = SIGNAL_CMAP((dbm + 90) / 60)
norm_val = (dbm - VMIN) / (VMAX - VMIN)
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))
ax.text(px, py - radius * 1.8, f"{int(round(dbm))}",
ha='center', va='bottom', fontsize=7, color='white',
fontweight='bold', zorder=6)
ha='center', va='bottom', fontsize=7,
color='white', fontweight='bold', zorder=6)
# Missing signal points — black circle with "?" label
for (px, py) in missing_points:
ax.add_patch(Circle((px, py), radius=radius,
facecolor='black', edgecolor='white',
linewidth=1.5, zorder=5, alpha=0.95))
ax.text(px, py, "?",
ha='center', va='center', fontsize=7, color='white',
fontweight='bold', zorder=6)
ha='center', va='center', fontsize=7,
color='white', fontweight='bold', zorder=6)
# ── Labels & styling ──────────────────────────────────────────────────────
total_points = len(points) + len(missing_points)
point_note = f"{total_points} measurement{'s' if total_points != 1 else ''}"
_draw_ap_markers(ax, session, bssids, radius)
# ── Labels & axes ─────────────────────────────────────────────────────────
total = len(points) + len(missing_points)
point_note = f"{total} measurement{'s' if total != 1 else ''}"
if missing_points:
point_note += f"{len(missing_points)} out of range (●)"
if len(bssids) > 1:
point_note += f" • avg of {len(bssids)} BSSIDs"
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"
ax.set_title(f"WiFi Heatmap\n{ap_label}", fontsize=12, fontweight='bold',
color='white', pad=10)
ax.set_title(f"WiFi Heatmap\n{ap_label}",
fontsize=12, fontweight='bold', color='white', pad=10)
ax.set_xlabel(point_note, fontsize=9, color='#888888')
ax.set_xlim(0, w)
ax.set_ylim(h, 0)
ax.set_xticks([])
ax.set_yticks([])
ax.set_facecolor('#0d0d1a')
fig.patch.set_facecolor('#0d0d1a')
if export_path:
fig.savefig(export_path, dpi=150, bbox_inches='tight',
@@ -201,19 +195,172 @@ 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:
- Signal ≥ VMAX (-30 dBm) → fully opaque (max_alpha)
- Signal ≤ FADE_FLOOR → fully transparent
- Between → cosine ramp for natural bubble-edge blending
Gaussian blur on alpha blends overlapping AP coverage zones smoothly.
"""
FADE_FLOOR = -85.0
BLUR_SIGMA = 8.0
res_h, res_w = grid.shape
norm = np.clip((grid - VMIN) / (VMAX - VMIN), 0.0, 1.0)
rgb_float = SIGNAL_CMAP(norm)[:, :, :3]
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))
alpha_raw[np.isnan(grid)] = 0.0
alpha_blur = gaussian_filter(alpha_raw, sigma=BLUR_SIGMA)
alpha_blur = np.clip(alpha_blur * max_alpha, 0.0, max_alpha)
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)
rgba[:, :, 2] = (rgb_float[:, :, 2] * 255).astype(np.uint8)
rgba[:, :, 3] = (alpha_blur * 255).astype(np.uint8)
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 whose physical position is known.
Visual design (intentionally different from measurement circles):
- Outer dashed pulse ring
- White-bordered diamond
- Signal-colored inner diamond
- Three WiFi arc lines above
- SSID label below
"""
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
# 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='--'))
# Outer white diamond
ax.add_patch(mpatches.RegularPolygon(
(px, py), numVertices=4, radius=r * 1.15,
orientation=np.pi / 4,
facecolor='white', edgecolor='white',
linewidth=0, zorder=8, alpha=0.95))
# 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)
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))
# WiFi arcs
for arc_r, arc_alpha in [(r * 0.55, 0.9), (r * 0.9, 0.65), (r * 1.25, 0.40)]:
ax.add_patch(mpatches.Arc(
(px, py - r * 0.15),
width=arc_r * 2, height=arc_r * 2,
angle=0, theta1=30, theta2=150,
color='white', linewidth=1.4,
zorder=10, alpha=arc_alpha))
# SSID label
ax.text(px, py + r * 2.2, ssid,
ha='center', va='top', fontsize=7,
color='white', fontweight='bold', zorder=10,
bbox=dict(boxstyle='round,pad=0.3',
facecolor='#000000', alpha=0.55,
edgecolor='none'))
# 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 technical grid when no floorplan is available."""
ax.set_facecolor('#0d0d1a')
grid_spacing = max(w, h) // 10
"""Draw a subtle coordinate grid when no floorplan is loaded."""
ax.set_facecolor('#1a1a1a')
spacing = max(w, h) // 10
for x in range(0, w + 1, grid_spacing):
ax.axvline(x, color='#1e2a4a', linewidth=0.6, zorder=0)
for y in range(0, h + 1, grid_spacing):
ax.axhline(y, color='#1e2a4a', linewidth=0.6, zorder=0)
for x in range(0, w + 1, spacing):
ax.axvline(x, color='#2a2a2a', linewidth=0.6, zorder=0)
for y in range(0, h + 1, spacing):
ax.axhline(y, color='#2a2a2a', linewidth=0.6, zorder=0)
for x in range(0, w + 1, grid_spacing * 2):
for x in range(0, w + 1, spacing * 2):
ax.text(x, h - 4, str(x), ha='center', va='bottom',
fontsize=6, color='#2a3a5a')
for y in range(0, h + 1, grid_spacing * 2):
fontsize=6, color='#3a3a3a')
for y in range(0, h + 1, spacing * 2):
ax.text(2, y, str(y), ha='left', va='center',
fontsize=6, color='#2a3a5a')
fontsize=6, color='#3a3a3a')
+418 -42
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 "-"
@@ -293,22 +550,32 @@ def _wlanapi_enumerate_and_scan(wlan, handle) -> list[AccessPoint]:
("InterfaceInfo", WLAN_INTERFACE_INFO * 64)]
# ── WLAN_BSS_ENTRY ────────────────────────────────────────────────────────
# DOT11_SSID is { ULONG uSSIDLength; UCHAR ucSSID[32]; } = 36 bytes.
# It must NOT be packed as c_ubyte*33 — uSSIDLength is a 4-byte ULONG,
# not a single byte, so the SSID data starts at offset 4, not offset 1.
class DOT11_SSID(ctypes.Structure):
_fields_ = [("uSSIDLength", wt.ULONG),
("ucSSID", ctypes.c_ubyte * 32)]
class WLAN_BSS_ENTRY(ctypes.Structure):
_fields_ = [
("dot11Ssid", ctypes.c_ubyte * 33), # DOT11_SSID (length + ucSSID)
("dot11Ssid", DOT11_SSID),
("uPhyId", wt.ULONG),
("dot11Bssid", ctypes.c_ubyte * 6),
("dot11BssType", wt.DWORD),
("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),
]
@@ -318,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))
@@ -339,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),
@@ -354,12 +679,22 @@ 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]
# Decode SSID (first byte = length in DOT11_SSID)
ssid_len = entry.dot11Ssid[0]
ssid_bytes = bytes(entry.dot11Ssid[1:1 + ssid_len])
# Decode SSID using the correct DOT11_SSID field layout:
# uSSIDLength is the number of valid bytes in ucSSID (max 32).
ssid_len = entry.dot11Ssid.uSSIDLength
ssid_bytes = bytes(entry.dot11Ssid.ucSSID[:ssid_len])
try:
ssid = ssid_bytes.decode("utf-8", errors="replace").strip("\x00")
except Exception:
@@ -379,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)
@@ -525,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"