Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb63e44a3c | ||
|
|
bd3daa548f | ||
|
|
29deced348 | ||
|
|
b6e0d0371a | ||
|
|
92f0ab2c38 | ||
|
|
f51e3e8785 | ||
|
|
bc2c3168bc | ||
|
|
44b9e1be09 | ||
|
|
d8d05c2318 | ||
|
|
6e9ca3acb1 | ||
|
|
009ea8c816 | ||
|
|
79f864c4aa | ||
|
|
b25cc5f29d | ||
|
|
0001a8a702 | ||
|
|
621978d1d6 | ||
|
|
bfdf025c37 | ||
|
|
329e81bf86 | ||
|
|
19b6447eaf | ||
|
|
7befa142a6 | ||
|
|
97c2c4d3bb | ||
|
|
1086a98e7b | ||
|
|
a76a7f221d | ||
|
|
3291fa0867 | ||
|
|
62c1b48093 | ||
|
|
95f8d1e2d2 | ||
|
|
1833e412c7 | ||
|
|
eccdbdcd78 | ||
|
|
5c98e8e4a6 | ||
|
|
80c5781daa | ||
|
|
e48aa38b70 | ||
|
|
10fde8278e | ||
|
|
060aa781b3 | ||
|
|
f1e0a1838e | ||
|
|
24efe2802c | ||
|
|
34213ba4ee | ||
|
|
988c40025e | ||
|
|
d4bdb340c8 | ||
|
|
f15ecc36e4 |
@@ -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) |
|
||||
| 4–8 | 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 10–15 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 0–100% 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 5925–7125 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/11–13)→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 0–4 = 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 0–4 = 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 0–12 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.
|
||||
@@ -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 15–20 measurement points** for a
|
||||
7. Repeat from step 6. Aim for **at least 15–20 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 8–15 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 30–60 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 4–7), 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>
|
||||
@@ -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"],
|
||||
|
||||
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 929 KiB |
@@ -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])])
|
||||
]
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||