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