Last active
May 10, 2026 09:10
-
-
Save severin-lemaignan/d3f854f6111e8275ae54fca14bb0e9b9 to your computer and use it in GitHub Desktop.
whatismywifi -- Linux client
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| whatismywifi — summarize the Wi-Fi capabilities of the local adapter and the | |
| characteristics of the currently active link, parsing `iw phy` and | |
| `iw dev <iface> link / station dump`. | |
| pip install whatismywifi (attention: Linux only) | |
| Inspired by https://www.wiisfi.com/ — explains what the values *mean*. | |
| Public Domain -- Written by Claude Opus 4.7. | |
| """ | |
| import argparse | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass, field | |
| from typing import Dict, List, Optional, Tuple | |
| # --------------------------------------------------------------------------- | |
| # Output helpers | |
| # --------------------------------------------------------------------------- | |
| class C: | |
| enabled = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None | |
| @classmethod | |
| def w(cls, s, code): | |
| return f"\033[{code}m{s}\033[0m" if cls.enabled else str(s) | |
| bold = classmethod(lambda c, s: c.w(s, "1")) | |
| dim = classmethod(lambda c, s: c.w(s, "2")) | |
| red = classmethod(lambda c, s: c.w(s, "31")) | |
| green = classmethod(lambda c, s: c.w(s, "32")) | |
| yellow = classmethod(lambda c, s: c.w(s, "33")) | |
| blue = classmethod(lambda c, s: c.w(s, "34")) | |
| magenta = classmethod(lambda c, s: c.w(s, "35")) | |
| cyan = classmethod(lambda c, s: c.w(s, "36")) | |
| def header(title: str): | |
| print() | |
| print(C.bold(C.cyan("═══ " + title + " ")) + C.cyan("═" * max(0, 60 - len(title)))) | |
| def kv(key: str, val: str, comment: str = ""): | |
| line = f" {C.bold(key.ljust(20))} {val}" | |
| if comment: | |
| line += " " + C.dim("— " + comment) | |
| print(line) | |
| def bullet(symbol: str, text: str, expl: str = ""): | |
| head = f" {symbol} {C.bold(text)}" | |
| if expl: | |
| head += " " + C.dim("— " + expl) | |
| print(head) | |
| # --------------------------------------------------------------------------- | |
| # Feature dictionary (built from wiisfi.com) | |
| # --------------------------------------------------------------------------- | |
| FEATURES = { | |
| # advanced WiFi-6/6E features | |
| "OFDMA": "Lets the AP slice a channel into sub-carriers and serve several " | |
| "clients in parallel — cuts contention in dense environments.", | |
| "MU-MIMO": "Multi-User MIMO: simultaneous spatial streams to several clients " | |
| "instead of one-at-a-time.", | |
| "TWT": "Target Wake Time — clients schedule precise wake intervals with " | |
| "the AP, drastically reducing idle radio time (battery saver).", | |
| "BSS Color": "Tags frames with a 6-bit colour so clients can ignore overlapping " | |
| "neighbour BSSs sharing the channel.", | |
| "1024-QAM": "10 bits/symbol modulation (WiFi 6); ~25 % faster than 256-QAM but " | |
| "needs a strong, clean signal.", | |
| "4096-QAM": "12 bits/symbol modulation (WiFi 7); +20 % over 1024-QAM with " | |
| "near-line-of-sight conditions.", | |
| "MLO": "Multi-Link Operation (WiFi 7): one association uses 2.4 / 5 / 6 GHz " | |
| "links concurrently for higher throughput and seamless failover.", | |
| "320 MHz": "WiFi 7 channel width — doubles 160 MHz, 6 GHz only.", | |
| "160 MHz": "Wide 5/6 GHz channel; doubles 80 MHz throughput when spectrum is " | |
| "free and the client is close to the AP.", | |
| "80 MHz": "Standard WiFi 5/6 channel width on 5 GHz.", | |
| "40 MHz": "Bonded 2×20 MHz; common on 2.4/5 GHz, often unusable on 2.4 GHz " | |
| "due to neighbour overlap.", | |
| "20 MHz": "Baseline channel width; most compatible, lowest throughput.", | |
| "Beamforming": "AP focuses RF energy toward the client antennas instead of " | |
| "broadcasting omni-directionally — better range and rate.", | |
| "MU Beamformee": "Client can be the receiving end of a multi-user beamformed " | |
| "transmission.", | |
| "SU Beamformee": "Client can receive single-user beamformed frames.", | |
| "LDPC": "Low-Density Parity-Check coding — stronger forward error " | |
| "correction than the legacy BCC code; better range / rate.", | |
| "STBC": "Space-Time Block Coding — antenna diversity that improves " | |
| "reliability when streams are not maxed out.", | |
| "Short GI": "Short Guard Interval (400 ns instead of 800 ns) — ~10 % faster " | |
| "PHY rate when multipath delay spread is low.", | |
| "11k": "802.11k Neighbor Reports — AP shares the list of nearby APs so " | |
| "the client makes smarter roaming decisions.", | |
| "11v": "802.11v BSS Transition Management — the AP can suggest a better " | |
| "AP for the client to roam to.", | |
| "11r": "802.11r Fast Transition — pre-authenticates with neighbour APs so " | |
| "roaming completes in <50 ms (good for VoIP).", | |
| "MFP": "Management Frame Protection (802.11w) — encrypts deauth/disassoc " | |
| "to prevent trivial denial-of-service.", | |
| "WPA3": "SAE-based authentication; resistant to offline dictionary attacks " | |
| "(replaces WPA2-PSK).", | |
| "DFS": "Dynamic Frequency Selection — required to share 5 GHz channels " | |
| "with weather/military radar.", | |
| "6 GHz": "New 1200 MHz of spectrum (WiFi 6E+); no legacy clutter, lots of " | |
| "wide channels.", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Running iw | |
| # --------------------------------------------------------------------------- | |
| def run(cmd: List[str]) -> str: | |
| try: | |
| out = subprocess.run(cmd, capture_output=True, text=True, check=False) | |
| except FileNotFoundError as e: | |
| sys.exit(f"error: {e}") | |
| if out.returncode != 0 and not out.stdout: | |
| sys.exit(f"error running {' '.join(cmd)}:\n{out.stderr.strip()}") | |
| return out.stdout | |
| # --------------------------------------------------------------------------- | |
| # Data classes | |
| # --------------------------------------------------------------------------- | |
| @dataclass | |
| class BandCaps: | |
| band_id: int | |
| freqs_mhz: List[Tuple[float, bool]] = field(default_factory=list) # (mhz, enabled) | |
| has_ht: bool = False | |
| has_vht: bool = False | |
| has_he: bool = False | |
| has_eht: bool = False | |
| widths: List[int] = field(default_factory=list) # 20/40/80/160/320 | |
| max_nss: int = 0 | |
| max_he_mcs: int = -1 | |
| max_vht_mcs: int = -1 | |
| max_ht_mcs: int = -1 | |
| su_beamformee: bool = False | |
| mu_beamformee: bool = False | |
| mu_mimo_ul: bool = False | |
| twt: bool = False | |
| bss_color: bool = False | |
| ldpc: bool = False | |
| stbc_rx: bool = False | |
| stbc_tx: bool = False | |
| short_gi_20: bool = False | |
| short_gi_40: bool = False | |
| qam_1024_tx: bool = False | |
| qam_4096_tx: bool = False | |
| @property | |
| def label(self) -> str: | |
| if not self.freqs_mhz: | |
| return f"Band {self.band_id}" | |
| f = self.freqs_mhz[0][0] | |
| if 2400 <= f <= 2500: | |
| return "2.4 GHz" | |
| if 5000 <= f <= 5900: | |
| return "5 GHz" | |
| if 5900 <= f <= 7200: | |
| return "6 GHz" | |
| return f"Band {self.band_id} ({f:.0f} MHz)" | |
| @property | |
| def enabled_channels(self) -> int: | |
| return sum(1 for _, en in self.freqs_mhz if en) | |
| @dataclass | |
| class PhyCaps: | |
| name: str = "" | |
| bands: Dict[int, BandCaps] = field(default_factory=dict) | |
| tx_antennas: int = 0 | |
| rx_antennas: int = 0 | |
| rrm: bool = False # 802.11k | |
| bss_transition: bool = False # 802.11v (commonly inferred) | |
| fast_transition: bool = False # 802.11r — not in iw phy directly, leave optional | |
| protected_twt: bool = False | |
| sae: bool = False # WPA3 | |
| mfp_capable: bool = False # always true on modern adapters; check ciphers | |
| ciphers: List[str] = field(default_factory=list) | |
| @dataclass | |
| class LinkInfo: | |
| iface: str = "" | |
| connected: bool = False | |
| ssid: str = "" | |
| bssid: str = "" | |
| freq_mhz: float = 0.0 | |
| width_mhz: int = 0 | |
| signal_dbm: Optional[int] = None | |
| tx_bitrate: float = 0.0 | |
| rx_bitrate: float = 0.0 | |
| tx_phy: str = "" # "HE" / "VHT" / "HT" / "EHT" / "legacy" | |
| rx_phy: str = "" | |
| tx_mcs: Optional[int] = None | |
| rx_mcs: Optional[int] = None | |
| tx_nss: Optional[int] = None | |
| rx_nss: Optional[int] = None | |
| tx_width: Optional[int] = None | |
| rx_width: Optional[int] = None | |
| tx_gi: Optional[int] = None | |
| rx_gi: Optional[int] = None | |
| tx_retries: Optional[int] = None | |
| tx_failed: Optional[int] = None | |
| connected_secs: Optional[int] = None | |
| # --------------------------------------------------------------------------- | |
| # Parsers | |
| # --------------------------------------------------------------------------- | |
| def detect_chipset(iface: str) -> Dict[str, str]: | |
| """Return {driver, bus, slot, vendor_device, marketing}.""" | |
| out: Dict[str, str] = {} | |
| uevent = f"/sys/class/net/{iface}/device/uevent" | |
| try: | |
| with open(uevent) as f: | |
| kv_data = dict(line.strip().split("=", 1) | |
| for line in f if "=" in line) | |
| except OSError: | |
| return out | |
| out["driver"] = kv_data.get("DRIVER", "") | |
| if "PCI_SLOT_NAME" in kv_data: | |
| slot = kv_data["PCI_SLOT_NAME"] | |
| out["bus"], out["slot"] = "PCI", slot | |
| if shutil.which("lspci"): | |
| try: | |
| txt = subprocess.run( | |
| ["lspci", "-k", "-nn", "-s", slot], | |
| capture_output=True, text=True, check=False).stdout | |
| # Line 1: "0000:00:14.3 Network ... : Intel Corp. Meteor Lake PCH CNVi WiFi [8086:7e40]" | |
| # Subsystem line: " Subsystem: Intel Corporation Wi-Fi 6E AX211 160MHz [8086:0094]" | |
| for line in txt.splitlines(): | |
| s = line.strip() | |
| if "Network controller" in s or "Wireless" in s: | |
| # "...[0280]: Intel Corporation X [8086:7e40] (rev 20)" | |
| m = re.search(r"\][^:]*:\s*(.+?)(?:\s*\[[0-9a-f]{4}:[0-9a-f]{4}\])(?:\s*\(rev[^)]*\))?\s*$", s) | |
| if m: | |
| out["vendor_device"] = m.group(1) | |
| if s.startswith("Subsystem:"): | |
| m = re.search(r"Subsystem:\s*(.+?)(?:\s*\[[0-9a-f]{4}:[0-9a-f]{4}\])?\s*$", s) | |
| if m: | |
| out["marketing"] = m.group(1) | |
| except OSError: | |
| pass | |
| if "vendor_device" not in out: | |
| ids = kv_data.get("PCI_ID", "") | |
| if ids: out["vendor_device"] = f"PCI {ids}" | |
| elif "PRODUCT" in kv_data: | |
| # USB: PRODUCT=vid/pid/rev | |
| out["bus"] = "USB" | |
| prod = kv_data["PRODUCT"].split("/") | |
| if len(prod) >= 2: | |
| vid, pid = prod[0].zfill(4), prod[1].zfill(4) | |
| out["slot"] = f"{vid}:{pid}" | |
| if shutil.which("lsusb"): | |
| try: | |
| txt = subprocess.run( | |
| ["lsusb", "-d", f"{vid}:{pid}"], | |
| capture_output=True, text=True, check=False).stdout.strip() | |
| # "Bus 001 Device 003: ID 0bda:b812 Realtek RTL88x2bu ..." | |
| m = re.search(r"ID [0-9a-f]{4}:[0-9a-f]{4}\s+(.+)$", txt) | |
| if m: out["vendor_device"] = m.group(1) | |
| except OSError: | |
| pass | |
| return out | |
| def discover_interface(preferred: Optional[str]) -> Tuple[str, str]: | |
| """Return (iface, phy).""" | |
| out = run(["iw", "dev"]) | |
| phy = "" | |
| iface = "" | |
| matches = [] | |
| for line in out.splitlines(): | |
| m = re.match(r"phy#(\d+)", line) | |
| if m: | |
| phy = f"phy{m.group(1)}" | |
| continue | |
| m = re.match(r"\s+Interface (\S+)", line) | |
| if m: | |
| matches.append((m.group(1), phy)) | |
| if not matches: | |
| sys.exit("no Wi-Fi interface found (is iw installed and a device present?)") | |
| if preferred: | |
| for iface, phy in matches: | |
| if iface == preferred: | |
| return iface, phy | |
| sys.exit(f"interface {preferred!r} not found. Available: " | |
| f"{', '.join(m[0] for m in matches)}") | |
| # prefer "managed" — fallback to the first | |
| for iface, phy in matches: | |
| info = run(["iw", "dev", iface, "info"]) | |
| if "type managed" in info: | |
| return iface, phy | |
| return matches[0] | |
| def parse_phy(text: str) -> PhyCaps: | |
| phy = PhyCaps() | |
| cur_band: Optional[BandCaps] = None | |
| in_freqs = False | |
| iftype_section = "" # "managed" / "AP" — we only use "managed" | |
| for raw in text.splitlines(): | |
| line = raw.rstrip() | |
| stripped = line.strip() | |
| m = re.match(r"Wiphy (\S+)", stripped) | |
| if m: | |
| phy.name = m.group(1) | |
| continue | |
| m = re.match(r"Available Antennas: TX (\S+) RX (\S+)", stripped) | |
| if m: | |
| phy.tx_antennas = bin(int(m.group(1), 16)).count("1") | |
| phy.rx_antennas = bin(int(m.group(2), 16)).count("1") | |
| continue | |
| m = re.match(r"Band (\d+):", stripped) | |
| if m: | |
| cur_band = BandCaps(band_id=int(m.group(1))) | |
| phy.bands[cur_band.band_id] = cur_band | |
| in_freqs = False | |
| iftype_section = "" | |
| continue | |
| if cur_band is None: | |
| # Phy-global features after bands | |
| if "RRM" in stripped and "[ RRM ]" in stripped: | |
| phy.rrm = True | |
| if "PROTECTED_TWT" in stripped: | |
| phy.protected_twt = True | |
| if "SAE" in stripped: | |
| phy.sae = True | |
| if stripped.startswith("* ") and "00-0f-ac" in stripped: | |
| phy.ciphers.append(stripped.split()[1]) | |
| continue | |
| # ---- inside a band ---- | |
| if stripped.startswith("HE Iftypes:"): | |
| iftype_section = stripped | |
| in_freqs = False | |
| continue | |
| if stripped.startswith("EHT Iftypes:"): | |
| iftype_section = stripped | |
| cur_band.has_eht = True | |
| in_freqs = False | |
| continue | |
| if stripped.startswith("Frequencies:"): | |
| in_freqs = True | |
| iftype_section = "" | |
| continue | |
| if stripped.startswith("Bitrates"): | |
| in_freqs = False | |
| continue | |
| if in_freqs: | |
| m = re.match(r"\* ([\d.]+) MHz \[\d+\](?:.*)$", stripped) | |
| if m: | |
| mhz = float(m.group(1)) | |
| enabled = "(disabled)" not in stripped | |
| cur_band.freqs_mhz.append((mhz, enabled)) | |
| continue | |
| # Capabilities lines — only consume from "managed" iftype to avoid AP variants | |
| # (most caps appear at the top of the band, before the iftype split — keep both) | |
| is_managed_or_top = (iftype_section == "" or "managed" in iftype_section) | |
| if "HT20/HT40" in stripped or "HT MCS rate" in stripped or "Max RX data rate" in stripped: | |
| cur_band.has_ht = True | |
| if "HT20/HT40" in stripped: | |
| if 20 not in cur_band.widths: cur_band.widths.append(20) | |
| if 40 not in cur_band.widths: cur_band.widths.append(40) | |
| if stripped.startswith("VHT Capabilities"): | |
| cur_band.has_vht = True | |
| if 80 not in cur_band.widths: cur_band.widths.append(80) | |
| if "Supported Channel Width: 160 MHz" in stripped: | |
| if 160 not in cur_band.widths: cur_band.widths.append(160) | |
| if stripped.startswith("HE PHY Capabilities") and is_managed_or_top: | |
| cur_band.has_he = True | |
| if "HE40/HE80/5GHz" in stripped: | |
| for w in (40, 80): | |
| if w not in cur_band.widths: cur_band.widths.append(w) | |
| if "HE160/5GHz" in stripped or "HE160" in stripped: | |
| if 160 not in cur_band.widths: cur_band.widths.append(160) | |
| if "EHT320" in stripped or "320 MHz" in stripped: | |
| if 320 not in cur_band.widths: cur_band.widths.append(320) | |
| cur_band.has_eht = True | |
| if "RX LDPC" in stripped or "LDPC Coding in Payload" in stripped: | |
| cur_band.ldpc = True | |
| if "RX STBC" in stripped: | |
| cur_band.stbc_rx = True | |
| if stripped == "TX STBC" or "STBC Tx" in stripped: | |
| cur_band.stbc_tx = True | |
| if "RX HT20 SGI" in stripped or "short GI (20" in stripped.lower(): | |
| cur_band.short_gi_20 = True | |
| if "RX HT40 SGI" in stripped or "short GI (40" in stripped.lower(): | |
| cur_band.short_gi_40 = True | |
| if "SU Beamformee" in stripped: | |
| cur_band.su_beamformee = True | |
| if "MU Beamformee" in stripped: | |
| cur_band.mu_beamformee = True | |
| if "Full Bandwidth UL MU-MIMO" in stripped or "UL MU-MIMO" in stripped: | |
| cur_band.mu_mimo_ul = True | |
| if "Broadcast TWT" in stripped or "TWT Responder" in stripped or "TWT Requester" in stripped: | |
| cur_band.twt = True | |
| if "BSS Color" in stripped or "BSS Coloring" in stripped: | |
| cur_band.bss_color = True | |
| if "TX 1024-QAM" in stripped: | |
| cur_band.qam_1024_tx = True | |
| if "TX 4096-QAM" in stripped: | |
| cur_band.qam_4096_tx = True | |
| # MCS / NSS counting (only "<= 80" or "160 MHz" sets in managed iftype) | |
| m = re.match(r"(\d+) streams: MCS 0-(\d+)", stripped) | |
| if m and is_managed_or_top: | |
| nss = int(m.group(1)) | |
| mcs = int(m.group(2)) | |
| if nss > cur_band.max_nss: | |
| cur_band.max_nss = nss | |
| # heuristics: HE has MCS 0-11, VHT 0-9, HT 0-7 | |
| if mcs >= 10: | |
| cur_band.max_he_mcs = max(cur_band.max_he_mcs, mcs) | |
| elif mcs == 9: | |
| cur_band.max_vht_mcs = max(cur_band.max_vht_mcs, mcs) | |
| else: | |
| cur_band.max_ht_mcs = max(cur_band.max_ht_mcs, mcs) | |
| # HT MCS rate line: "HT TX/RX MCS rate indexes supported: 0-15" → up to 2 streams | |
| m = re.match(r"HT TX/RX MCS rate indexes supported: 0-(\d+)", stripped) | |
| if m: | |
| top = int(m.group(1)) | |
| # 0-7 → 1 stream, 0-15 → 2, 0-23 → 3, 0-31 → 4 | |
| ht_nss = (top // 8) + 1 | |
| if ht_nss > cur_band.max_nss: | |
| cur_band.max_nss = ht_nss | |
| return phy | |
| def parse_link(text: str) -> LinkInfo: | |
| link = LinkInfo() | |
| if "Not connected" in text or text.strip() == "": | |
| link.connected = False | |
| return link | |
| link.connected = True | |
| for raw in text.splitlines(): | |
| s = raw.strip() | |
| m = re.match(r"Connected to ([0-9a-f:]+)", s) | |
| if m: link.bssid = m.group(1); continue | |
| m = re.match(r"SSID: (.*)$", s) | |
| if m: link.ssid = m.group(1); continue | |
| m = re.match(r"freq: ([\d.]+)", s) | |
| if m: link.freq_mhz = float(m.group(1)); continue | |
| m = re.match(r"signal: (-?\d+) dBm", s) | |
| if m: link.signal_dbm = int(m.group(1)); continue | |
| for direction in ("rx", "tx"): | |
| m = re.match(rf"{direction} bitrate: ([\d.]+) MBit/s(.*)$", s) | |
| if m: | |
| rate = float(m.group(1)) | |
| rest = m.group(2) | |
| phy_kind = "legacy" | |
| if "EHT" in rest: phy_kind = "EHT" | |
| elif "HE" in rest: phy_kind = "HE" | |
| elif "VHT" in rest: phy_kind = "VHT" | |
| elif "MCS" in rest: phy_kind = "HT" | |
| mcs = nss = width = gi = None | |
| mw = re.search(r"(\d+)MHz", rest) | |
| if mw: width = int(mw.group(1)) | |
| mm = re.search(r"(?:HE|VHT|EHT)?-MCS (\d+)", rest) | |
| if mm: mcs = int(mm.group(1)) | |
| else: | |
| mm = re.search(r"\bMCS (\d+)", rest) | |
| if mm: mcs = int(mm.group(1)) | |
| mn = re.search(r"(?:HE|VHT|EHT)-NSS (\d+)", rest) | |
| if mn: nss = int(mn.group(1)) | |
| mg = re.search(r"(?:HE|VHT|EHT)-GI (\d+)", rest) | |
| if mg: gi = int(mg.group(1)) | |
| if direction == "rx": | |
| link.rx_bitrate, link.rx_phy = rate, phy_kind | |
| link.rx_mcs, link.rx_nss, link.rx_width, link.rx_gi = mcs, nss, width, gi | |
| else: | |
| link.tx_bitrate, link.tx_phy = rate, phy_kind | |
| link.tx_mcs, link.tx_nss, link.tx_width, link.tx_gi = mcs, nss, width, gi | |
| continue | |
| return link | |
| def parse_station(text: str, link: LinkInfo) -> LinkInfo: | |
| for raw in text.splitlines(): | |
| s = raw.strip() | |
| m = re.match(r"tx retries:\s*(\d+)", s) | |
| if m: link.tx_retries = int(m.group(1)); continue | |
| m = re.match(r"tx failed:\s*(\d+)", s) | |
| if m: link.tx_failed = int(m.group(1)); continue | |
| m = re.match(r"connected time:\s*(\d+) seconds", s) | |
| if m: link.connected_secs = int(m.group(1)); continue | |
| return link | |
| def parse_iface_info(text: str) -> Dict[str, str]: | |
| info = {} | |
| for raw in text.splitlines(): | |
| s = raw.strip() | |
| m = re.match(r"channel (\d+) \((\d+) MHz\), width: (\d+) MHz", s) | |
| if m: | |
| info["channel"] = m.group(1) | |
| info["freq"] = m.group(2) | |
| info["width"] = m.group(3) | |
| m = re.match(r"txpower ([\d.]+) dBm", s) | |
| if m: | |
| info["txpower"] = m.group(1) | |
| return info | |
| # --------------------------------------------------------------------------- | |
| # Higher-level interpretation | |
| # --------------------------------------------------------------------------- | |
| def adapter_generation(phy: PhyCaps) -> Tuple[str, str]: | |
| has_6g = any(b.label == "6 GHz" for b in phy.bands.values()) | |
| has_eht = any(b.has_eht for b in phy.bands.values()) | |
| has_he = any(b.has_he for b in phy.bands.values()) | |
| has_vht = any(b.has_vht for b in phy.bands.values()) | |
| has_ht = any(b.has_ht for b in phy.bands.values()) | |
| if has_eht: | |
| return "WiFi 7", "802.11be — Extremely High Throughput, 320 MHz, 4096-QAM, MLO." | |
| if has_he and has_6g: | |
| return "WiFi 6E", "802.11ax extended into 6 GHz — clean spectrum, wide channels." | |
| if has_he: | |
| return "WiFi 6", "802.11ax — OFDMA, MU-MIMO UL/DL, 1024-QAM, TWT." | |
| if has_vht: | |
| return "WiFi 5", "802.11ac — 5 GHz only, 80/160 MHz, 256-QAM." | |
| if has_ht: | |
| return "WiFi 4", "802.11n — first MIMO/HT generation." | |
| return "Pre-WiFi 4", "Legacy 802.11a/b/g." | |
| def link_generation(link: LinkInfo) -> str: | |
| p = link.rx_phy or link.tx_phy | |
| if p == "EHT": return "WiFi 7" | |
| if p == "HE": | |
| return "WiFi 6E" if link.freq_mhz >= 5925 else "WiFi 6" | |
| if p == "VHT": return "WiFi 5" | |
| if p == "HT": return "WiFi 4" | |
| return "legacy 802.11a/b/g" | |
| def estimated_modulation(phy_kind: str, mcs: Optional[int]) -> str: | |
| if mcs is None: return "?" | |
| if phy_kind == "HE": | |
| return {0: "BPSK 1/2", 1: "QPSK 1/2", 2: "QPSK 3/4", 3: "16-QAM 1/2", | |
| 4: "16-QAM 3/4", 5: "64-QAM 2/3", 6: "64-QAM 3/4", 7: "64-QAM 5/6", | |
| 8: "256-QAM 3/4", 9: "256-QAM 5/6", 10: "1024-QAM 3/4", | |
| 11: "1024-QAM 5/6"}.get(mcs, f"MCS {mcs}") | |
| if phy_kind == "EHT": | |
| return {12: "4096-QAM 3/4", 13: "4096-QAM 5/6"}.get(mcs, f"MCS {mcs}") | |
| if phy_kind == "VHT": | |
| return {0: "BPSK", 1: "QPSK 1/2", 2: "QPSK 3/4", 3: "16-QAM 1/2", | |
| 4: "16-QAM 3/4", 5: "64-QAM 2/3", 6: "64-QAM 3/4", 7: "64-QAM 5/6", | |
| 8: "256-QAM 3/4", 9: "256-QAM 5/6"}.get(mcs, f"MCS {mcs}") | |
| if phy_kind == "HT": | |
| return f"HT MCS {mcs}" | |
| return "—" | |
| def signal_quality(dbm: int) -> Tuple[str, str]: | |
| if dbm >= -50: return C.green("excellent"), "near AP, supports highest QAM" | |
| if dbm >= -60: return C.green("very good"), "supports 256/1024-QAM comfortably" | |
| if dbm >= -67: return C.yellow("good"), "fine for streaming/calls" | |
| if dbm >= -75: return C.yellow("fair"), "modulation will drop, range edge" | |
| if dbm >= -85: return C.red("weak"), "low throughput, retries likely" | |
| return C.red("unusable"), "association may drop" | |
| # --------------------------------------------------------------------------- | |
| # Reporting | |
| # --------------------------------------------------------------------------- | |
| def report_adapter(phy: PhyCaps, chipset: Dict[str, str]): | |
| gen, gen_blurb = adapter_generation(phy) | |
| header(f"Adapter capabilities ({phy.name})") | |
| if chipset: | |
| marketing = chipset.get("marketing") or chipset.get("vendor_device") or "unknown" | |
| chip_extra = [] | |
| if chipset.get("vendor_device") and chipset.get("vendor_device") != marketing: | |
| chip_extra.append(chipset["vendor_device"]) | |
| if chipset.get("driver"): | |
| chip_extra.append(f"driver: {chipset['driver']}") | |
| if chipset.get("bus") and chipset.get("slot"): | |
| chip_extra.append(f"{chipset['bus']} {chipset['slot']}") | |
| kv("Chipset", C.bold(marketing), | |
| " · ".join(chip_extra) if chip_extra else "") | |
| kv("Highest standard", C.bold(C.green(gen)), gen_blurb) | |
| bands = list(phy.bands.values()) | |
| band_str = ", ".join(f"{b.label} ({b.enabled_channels} ch)" for b in bands) | |
| kv("Bands supported", band_str) | |
| max_nss = max((b.max_nss for b in bands), default=0) | |
| if phy.tx_antennas and phy.rx_antennas: | |
| kv("MIMO config", f"{phy.tx_antennas}×{phy.rx_antennas} antennas, " | |
| f"up to {max_nss} spatial stream(s)", | |
| "more streams ≈ proportionally more throughput") | |
| max_w = max((max(b.widths, default=0) for b in bands), default=0) | |
| kv("Max channel width", f"{max_w} MHz" if max_w else "?", | |
| "wider = faster, but needs proximity & free spectrum") | |
| if any(b.qam_4096_tx for b in bands): | |
| kv("Max modulation", "4096-QAM (12 bits/symbol)", "WiFi 7") | |
| elif any(b.qam_1024_tx for b in bands): | |
| kv("Max modulation", "1024-QAM (10 bits/symbol)", "WiFi 6") | |
| elif any(b.has_vht for b in bands): | |
| kv("Max modulation", "256-QAM (8 bits/symbol)", "WiFi 5") | |
| elif any(b.has_ht for b in bands): | |
| kv("Max modulation", "64-QAM") | |
| print() | |
| print(C.bold(" Per-band detail:")) | |
| for b in bands: | |
| flags = [] | |
| if b.has_eht: flags.append("EHT") | |
| if b.has_he: flags.append("HE") | |
| if b.has_vht: flags.append("VHT") | |
| if b.has_ht: flags.append("HT") | |
| print(f" • {C.cyan(b.label):<22} widths {sorted(b.widths)} MHz, " | |
| f"NSS≤{b.max_nss}, {'/'.join(flags) or '-'}") | |
| # Feature roll-up across bands | |
| print() | |
| print(C.bold(" Notable features supported:")) | |
| feats = set() | |
| for b in bands: | |
| if b.su_beamformee: feats.add("SU Beamformee") | |
| if b.mu_beamformee: feats.add("MU Beamformee") | |
| if b.mu_mimo_ul: feats.add("MU-MIMO") | |
| if b.twt: feats.add("TWT") | |
| if b.bss_color: feats.add("BSS Color") | |
| if b.ldpc: feats.add("LDPC") | |
| if b.stbc_rx or b.stbc_tx: feats.add("STBC") | |
| if 160 in b.widths: feats.add("160 MHz") | |
| if 320 in b.widths: feats.add("320 MHz") | |
| if b.qam_1024_tx: feats.add("1024-QAM") | |
| if b.qam_4096_tx: feats.add("4096-QAM") | |
| if any(b.label == "6 GHz" for b in bands): | |
| feats.add("6 GHz") | |
| if phy.rrm: feats.add("11k") | |
| if phy.protected_twt: feats.add("TWT") | |
| if phy.sae: feats.add("WPA3") | |
| # Print explanation for each feature | |
| for name in sorted(feats): | |
| bullet("✓", name, FEATURES.get(name, "")) | |
| def report_link(link: LinkInfo, iface_info: Dict[str, str]): | |
| header(f"Active link ({link.iface})") | |
| if not link.connected: | |
| print(" " + C.yellow("not associated to any network")) | |
| return | |
| gen = link_generation(link) | |
| band = "2.4 GHz" if link.freq_mhz < 3000 else ( | |
| "6 GHz" if link.freq_mhz >= 5925 else "5 GHz") | |
| kv("SSID / BSSID", f"{C.bold(link.ssid)} ({link.bssid})") | |
| chan = iface_info.get("channel", "?") | |
| width = iface_info.get("width", str(link.rx_width or link.tx_width or "?")) | |
| kv("Channel", f"{chan} on {band} ({link.freq_mhz:.0f} MHz, {width} MHz wide)") | |
| kv("Generation in use", C.bold(C.green(gen)), | |
| f"{link.rx_phy} PHY frames") | |
| if link.signal_dbm is not None: | |
| q, q_expl = signal_quality(link.signal_dbm) | |
| kv("Signal", f"{link.signal_dbm} dBm ({q})", q_expl) | |
| rx_mod = estimated_modulation(link.rx_phy, link.rx_mcs) | |
| tx_mod = estimated_modulation(link.tx_phy, link.tx_mcs) | |
| kv("RX rate", f"{link.rx_bitrate:>7.1f} Mbps " | |
| f"MCS {link.rx_mcs} / NSS {link.rx_nss} / GI {link.rx_gi}", rx_mod) | |
| kv("TX rate", f"{link.tx_bitrate:>7.1f} Mbps " | |
| f"MCS {link.tx_mcs} / NSS {link.tx_nss} / GI {link.tx_gi}", tx_mod) | |
| # rough application-layer estimate (~65% of PHY) | |
| kv("Est. throughput", f"~{int(min(link.rx_bitrate, link.tx_bitrate) * 0.65)} Mbps " | |
| "useful payload", "WiFi MAC overhead ≈ 30–40 %") | |
| if link.connected_secs is not None: | |
| kv("Associated for", f"{link.connected_secs} s") | |
| if link.tx_retries is not None: | |
| rate_pct = (link.tx_failed / link.tx_retries * 100) if link.tx_retries else 0.0 | |
| kv("TX retries / failed", | |
| f"{link.tx_retries} / {link.tx_failed}", | |
| f"failure rate ~{rate_pct:.1f} % of retries — " | |
| f"{'OK' if (link.tx_failed or 0) < 50 else 'investigate'}") | |
| def report_discrepancies(phy: PhyCaps, link: LinkInfo): | |
| header("Used vs supported — what you're leaving on the table") | |
| if not link.connected: | |
| print(" (no active link)") | |
| return | |
| notes = [] | |
| # generation | |
| sup_gen, _ = adapter_generation(phy) | |
| use_gen = link_generation(link) | |
| if sup_gen != use_gen: | |
| notes.append((C.yellow, | |
| f"Adapter is {sup_gen} but the link is operating as {use_gen}.", | |
| f"the AP either doesn't advertise {sup_gen} or you're on a band that " | |
| f"doesn't support it.")) | |
| # band — 6 GHz available but unused | |
| if any(b.label == "6 GHz" for b in phy.bands.values()) and link.freq_mhz < 5925: | |
| notes.append((C.yellow, | |
| "Adapter supports the 6 GHz band (WiFi 6E) but you are connected on " | |
| f"{('5' if link.freq_mhz>=3000 else '2.4')} GHz.", | |
| "if your AP has a 6 GHz radio, switching to it gives clean spectrum " | |
| "and wider channels.")) | |
| # width | |
| band_idx = None | |
| for bid, b in phy.bands.items(): | |
| if b.freqs_mhz and b.freqs_mhz[0][0] <= link.freq_mhz <= b.freqs_mhz[-1][0]: | |
| band_idx = bid | |
| if band_idx is not None: | |
| b = phy.bands[band_idx] | |
| max_w = max(b.widths, default=0) | |
| cur_w = link.rx_width or link.tx_width or 0 | |
| if max_w and cur_w and cur_w < max_w: | |
| notes.append((C.yellow, | |
| f"Channel is {cur_w} MHz wide but adapter+band can do {max_w} MHz.", | |
| "AP likely advertises a narrower primary channel, or wider bonding " | |
| "is unavailable here (DFS / neighbour interference).")) | |
| # NSS | |
| max_nss = max((b.max_nss for b in phy.bands.values()), default=0) | |
| used_nss = max(link.rx_nss or 0, link.tx_nss or 0) | |
| if max_nss and used_nss and used_nss < max_nss: | |
| notes.append((C.yellow, | |
| f"Only {used_nss} spatial stream(s) negotiated, adapter supports " | |
| f"{max_nss}.", | |
| "AP has fewer antennas than the adapter, or signal is too weak to " | |
| "sustain another stream.")) | |
| # MCS / modulation ceiling | |
| max_mcs_he = max((b.max_he_mcs for b in phy.bands.values()), default=-1) | |
| used_mcs = link.rx_mcs if link.rx_mcs is not None else -1 | |
| if link.rx_phy == "HE" and max_mcs_he >= 11 and used_mcs < 11: | |
| notes.append((C.yellow, | |
| f"RX modulation is MCS {used_mcs} ({estimated_modulation('HE', used_mcs)}) " | |
| f"while adapter can do MCS 11 (1024-QAM 5/6).", | |
| "needs stronger signal / less interference to climb.")) | |
| if link.tx_mcs is not None and link.tx_mcs < (link.rx_mcs or 0): | |
| notes.append((C.dim, | |
| f"TX MCS ({link.tx_mcs}) is below RX MCS ({link.rx_mcs}).", | |
| "asymmetric link — typical when the AP transmits more cleanly than the " | |
| "client; the rate-control algorithm errs on the safe side.")) | |
| # GI | |
| if link.rx_gi == 2: | |
| notes.append((C.dim, | |
| "Long Guard Interval (3.2 µs) in use.", | |
| "OFDMA / multipath robustness is preferred over peak speed; expected on " | |
| "WiFi 6 in many APs.")) | |
| # signal-driven note | |
| if link.signal_dbm is not None and link.signal_dbm < -65: | |
| notes.append((C.yellow, | |
| "Signal is below −65 dBm.", | |
| "rate adaptation will already have backed off from peak modulation; " | |
| "moving closer or removing obstructions yields the biggest gain.")) | |
| if not notes: | |
| print(" " + C.green("nothing obvious — link is using the adapter close to its peak.")) | |
| return | |
| for color, title, expl in notes: | |
| bullet(color("⚑"), title, expl) | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main(): | |
| ap = argparse.ArgumentParser( | |
| description="Summarize Wi-Fi adapter capabilities and the active link.", | |
| epilog="Reads from `iw`. Inspired by https://www.wiisfi.com/") | |
| ap.add_argument("-i", "--interface", help="wifi interface (auto-detected if omitted)") | |
| ap.add_argument("--no-color", action="store_true", help="disable ANSI colors") | |
| args = ap.parse_args() | |
| if args.no_color: | |
| C.enabled = False | |
| if shutil.which("iw") is None: | |
| sys.exit("error: `iw` not found in PATH. Install iproute2/iw first.") | |
| iface, phy_name = discover_interface(args.interface) | |
| phy_text = run(["iw", phy_name, "info"]) | |
| link_text = run(["iw", "dev", iface, "link"]) | |
| info_text = run(["iw", "dev", iface, "info"]) | |
| stat_text = run(["iw", "dev", iface, "station", "dump"]) | |
| phy = parse_phy(phy_text) | |
| if not phy.name: | |
| phy.name = phy_name | |
| link = parse_link(link_text) | |
| link.iface = iface | |
| if link.connected: | |
| link = parse_station(stat_text, link) | |
| iface_info = parse_iface_info(info_text) | |
| chipset = detect_chipset(iface) | |
| print(C.bold(C.magenta("\n wiisfi — local Wi-Fi capability & link summary"))) | |
| print(C.dim(f" iface={iface} phy={phy.name}")) | |
| report_adapter(phy, chipset) | |
| report_link(link, iface_info) | |
| report_discrepancies(phy, link) | |
| print() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment