# -*- coding: utf-8 -*- # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import re from typing import Any class OpenWrtWirelessMixin: """Mixin providing wireless/radio NAPALM getters.""" def get_ssids(self) -> dict[str, Any]: """Return configured SSIDs from UCI wireless configuration. Parses ``uci show wireless`` for ``wifi-iface`` entries and enriches each entry with: * ``band`` — human-readable frequency band ("2.4 GHz", "5 GHz", "6 GHz") derived from the radio's ``band`` or ``hwmode`` UCI key. * ``encryption`` — human-readable security mode ("WPA2-PSK", "Open", …). When the same SSID name is broadcast on multiple radios, the keys in the returned dict are disambiguated as ``"ssid (2.4 GHz)"`` / ``"ssid (5 GHz)"``. """ uci_out = self._send_command("uci show wireless") # Collect radio band info: radio0 → "2g", radio1 → "5g", … radio_bands: dict[str, str] = {} iface_entries: dict[str, dict[str, str]] = {} # UCI list values (e.g. "list maclist 'AA:...'") repeat the same key # across multiple lines — tracked separately since the single-value # iface_entries dict would only keep the last one. iface_maclists: dict[str, list[str]] = {} # First pass: identify named sections that are wifi-iface types and # collect radio band info. named_iface_sections: set = set() for line in uci_out.splitlines(): line_s = line.strip() # named wifi-iface declaration: wireless.managed_family_2g=wifi-iface nm = re.match(r"wireless\.(\w+)=wifi-iface", line_s) if nm: named_iface_sections.add(nm.group(1)) continue # radio device config: wireless.radio0.band='2g' rm = re.match(r"wireless\.(radio\d+)\.(band|hwmode)='([^']*)'", line_s) if rm: radio, key, val = rm.group(1), rm.group(2), rm.group(3) if key == "band" or radio not in radio_bands: radio_bands[radio] = val # Second pass: collect iface properties (both anonymous and named sections) for line in uci_out.splitlines(): line_s = line.strip() # radio device config (already handled above) rm = re.match(r"wireless\.(radio\d+)\.(band|hwmode)='([^']*)'", line_s) if rm: radio, key, val = rm.group(1), rm.group(2), rm.group(3) # Prefer 'band' over 'hwmode' when both present if key == "band" or radio not in radio_bands: radio_bands[radio] = val continue # anonymous wifi-iface values: wireless.@wifi-iface[0].ssid='MyNet' im = re.match(r"wireless\.@wifi-iface\[(\d+)\]\.(\w+)='([^']*)'", line_s) if im: idx, key, val = im.group(1), im.group(2), im.group(3) if key == "maclist": iface_maclists.setdefault(idx, []).append(val) else: iface_entries.setdefault(idx, {})[key] = val continue # named wifi-iface values: wireless.managed_family_2g.ssid='manivong' nm = re.match(r"wireless\.(\w+)\.(\w+)='([^']*)'", line_s) if nm and nm.group(1) in named_iface_sections: section, key, val = nm.group(1), nm.group(2), nm.group(3) if key == "maclist": iface_maclists.setdefault(section, []).append(val) else: iface_entries.setdefault(section, {})[key] = val def _band_label(radio: str) -> str: raw = radio_bands.get(radio, "").lower() if raw in ("2g", "11g", "b", "g", "bg", "bgn", "b/g", "b/g/n"): return "2.4 GHz" if raw in ("5g", "11a", "a", "ac", "ax5", "a/n", "a/n/ac"): return "5 GHz" if raw in ("6g", "ax6"): return "6 GHz" return "" _ENC_MAP = { "": "Open", "none": "Open", "0": "Open", "wep": "WEP", "wep-open": "WEP (Open)", "wep-shared": "WEP (Shared)", "psk": "WPA-PSK", "psk+ccmp": "WPA-PSK", "psk-mixed": "WPA/WPA2-PSK", "psk2": "WPA2-PSK", "psk2+ccmp": "WPA2-PSK", "psk2+aes": "WPA2-PSK", "psk3": "WPA3-SAE", "psk2+psk3": "WPA2/WPA3", "sae": "WPA3-SAE", "sae-mixed": "WPA2/WPA3", "wpa": "WPA-Enterprise", "wpa2": "WPA2-Enterprise", "wpa3": "WPA3-Enterprise", "ccmp": "WPA2-PSK", } def _enc_label(enc_raw: str) -> str: return _ENC_MAP.get(enc_raw.lower(), enc_raw.upper() or "Open") # Build network→vlan_id map from UCI network config. # A wifi-iface has option network='ap_7'; the corresponding UCI network # interface has either an explicit vid ('7') or a bridge device whose # name encodes the VLAN, e.g. br-ap.7 → VLAN 7. def _vlan_from_device(dev: str) -> int | None: m = re.search(r"\.(\d+)$", dev) if m: return int(m.group(1)) return None net_vlan: dict[str, int] = {} try: net_out = self._send_command("uci show network 2>/dev/null || true") net_entries: dict[str, dict[str, str]] = {} for line in net_out.splitlines(): line_s = line.strip() m = re.match(r"network\.(\w+)\.(\w+)='([^']*)'", line_s) if m: iface, key, val = m.group(1), m.group(2), m.group(3) net_entries.setdefault(iface, {})[key] = val for iface, props in net_entries.items(): vid_str = props.get("vid") or props.get("vlan") if vid_str and vid_str.isdigit(): net_vlan[iface] = int(vid_str) continue dev = props.get("device", "") vlan = _vlan_from_device(dev) if vlan is not None: net_vlan[iface] = vlan except Exception: pass # Non-fatal: VLAN info is optional enrichment # Build result; group entries with the same SSID name, merging bands result: dict[str, Any] = {} # Intermediate: ssid -> list of bands seen ssid_bands: dict[str, list[str]] = {} _ACL_MODE_MAP = {"allow": "whitelist", "deny": "blacklist"} for idx, entry in iface_entries.items(): ssid = entry.get("ssid") if not ssid: continue radio = entry.get("device", "") band = _band_label(radio) disabled = entry.get("disabled", "0") == "1" enc_raw = entry.get("encryption", "") or "" encryption = _enc_label(enc_raw) hidden = entry.get("hidden", "0") == "1" network_name = entry.get("network", "") vlan_id: int | None = net_vlan.get(network_name) ft_enabled = entry.get("ieee80211r", "0") == "1" ft_mobility_domain = entry.get("mobility_domain", "") ft_over_ds = entry.get("ft_over_ds", "1") == "1" client_isolation = entry.get("isolate", "0") == "1" _max_raw = entry.get("maxassoc") max_clients: int | None = int(_max_raw) if _max_raw and str(_max_raw).isdigit() else None _disassoc_raw = entry.get("disassoc_low_ack") disassoc_low_ack: bool | None = (_disassoc_raw == "1") if _disassoc_raw is not None else None _max_inact_raw = entry.get("max_inactivity") max_inactivity: int | None = int(_max_inact_raw) if _max_inact_raw and str(_max_inact_raw).isdigit() else None key: str = entry.get("key", "") or "" acl_mode = _ACL_MODE_MAP.get(entry.get("macfilter", ""), "off") mac_list = sorted(set(iface_maclists.get(idx, []))) if ssid in result: # Merge: append band if not already present if band and band not in ssid_bands[ssid]: ssid_bands[ssid].append(band) # Keep alphabetical order so 2.4 GHz comes before 5 GHz ssid_bands[ssid].sort() result[ssid]["band"] = " + ".join(ssid_bands[ssid]) result[ssid]["bands_list"] = list(ssid_bands[ssid]) # If one radio is enabled, the SSID counts as enabled if not disabled: result[ssid]["enabled"] = True # Keep vlan_id if not yet set if result[ssid].get("vlan_id") is None and vlan_id is not None: result[ssid]["vlan_id"] = vlan_id # FT: if any radio has ieee80211r enabled, mark the SSID as FT-enabled if ft_enabled: result[ssid]["ieee80211r"] = True result[ssid]["mobility_domain"] = ft_mobility_domain result[ssid]["ft_over_ds"] = ft_over_ds # Client isolation: if any iface has it, mark True if client_isolation: result[ssid]["client_isolation"] = True # Max clients: keep first non-None value if max_clients is not None and result[ssid].get("max_clients") is None: result[ssid]["max_clients"] = max_clients # disassoc_low_ack / max_inactivity: keep first explicit value if disassoc_low_ack is not None and result[ssid].get("disassoc_low_ack") is None: result[ssid]["disassoc_low_ack"] = disassoc_low_ack if max_inactivity is not None and result[ssid].get("max_inactivity") is None: result[ssid]["max_inactivity"] = max_inactivity # key: keep first non-empty value seen if key and not result[ssid].get("key"): result[ssid]["key"] = key # acl_mode/mac_list: keep first non-"off" value seen — all # wifi-iface sections for one SSID carry identical ACL config # after a push, so any explicit value wins over the default. if acl_mode != "off" and result[ssid].get("acl_mode", "off") == "off": result[ssid]["acl_mode"] = acl_mode result[ssid]["mac_list"] = mac_list else: ssid_bands[ssid] = [band] if band else [] result[ssid] = { "enabled": not disabled, "radio": radio, "band": band, "bands_list": list(ssid_bands[ssid]), "bssid": "", "encryption": encryption, "encryption_uci": enc_raw, "hidden": hidden, "client_isolation": client_isolation, "max_clients": max_clients, "clients": 0, "vlan_id": vlan_id, "ieee80211r": ft_enabled, "mobility_domain": ft_mobility_domain, "ft_over_ds": ft_over_ds, "disassoc_low_ack": disassoc_low_ack, "max_inactivity": max_inactivity, "key": key, "acl_mode": acl_mode, "mac_list": mac_list, } return result def get_wireless_clients(self) -> list[dict[str, Any]]: """Return currently associated wireless clients from all AP interfaces. Uses ``iw dev`` to discover AP-mode interfaces and then ``iw dev station dump`` to collect per-client statistics. """ from napalm_device_types.models import WirelessClientDict # Step 1: discover interfaces and their SSIDs / radio mappings iw_out = self._send_command("iw dev 2>/dev/null || true") iface_info: dict[str, dict[str, str]] = {} current_phy: str = "" current_iface: str = "" for line in iw_out.splitlines(): stripped = line.strip() phy_m = re.match(r"^phy#(\d+)$", stripped) if phy_m: current_phy = f"radio{phy_m.group(1)}" current_iface = "" continue iface_m = re.match(r"^Interface\s+(\S+)$", stripped) if iface_m: current_iface = iface_m.group(1) iface_info[current_iface] = {"ssid": "", "radio": current_phy, "type": ""} continue if not current_iface: continue ssid_m = re.match(r"^ssid\s+(.+)$", stripped) if ssid_m: iface_info[current_iface]["ssid"] = ssid_m.group(1) continue type_m = re.match(r"^type\s+(\S+)$", stripped) if type_m: iface_info[current_iface]["type"] = type_m.group(1) continue # channel 6 (2437 MHz), width: 20 MHz, ... chan_m = re.match(r"^channel\s+\d+\s+\((\d+)\s+MHz\)", stripped) if chan_m: try: freq = int(chan_m.group(1)) if freq < 3000: iface_info[current_iface]["band"] = "2.4 GHz" elif freq < 6000: iface_info[current_iface]["band"] = "5 GHz" else: iface_info[current_iface]["band"] = "6 GHz" except ValueError: pass # Filter to AP-mode interfaces only ap_ifaces = { name: info for name, info in iface_info.items() if info.get("type", "").upper() in ("AP", "AP/VLAN") } if not ap_ifaces: return [] # Step 2: fetch station dumps for all AP interfaces in one SSH call dump_cmd = " ; ".join( f"echo '=== {name} ===' && iw dev {name} station dump 2>/dev/null || true" for name in ap_ifaces ) station_out = self._send_command(dump_cmd) # Step 3: parse station dump output results: list[dict[str, Any]] = [] active_iface: str = "" current_station: dict[str, Any] | None = None def _flush() -> None: if current_station and current_station.get("mac"): info = ap_ifaces.get(active_iface, {}) results.append(WirelessClientDict( mac=current_station["mac"], ssid=info.get("ssid", ""), radio=info.get("band") or info.get("radio", ""), signal=current_station.get("signal", 0), noise=0, tx_rate=current_station.get("tx_rate", 0.0), rx_rate=current_station.get("rx_rate", 0.0), uptime=current_station.get("uptime", 0), )) for line in station_out.splitlines(): stripped = line.strip() # Section header injected above: === wlan0 === hdr_m = re.match(r"^=== (\S+) ===$", stripped) if hdr_m: _flush() active_iface = hdr_m.group(1) current_station = None continue # Station aa:bb:cc:dd:ee:ff (on wlan0) sta_m = re.match(r"^Station\s+([\da-fA-F:]{17})\s+\(", stripped) if sta_m: _flush() current_station = {"mac": sta_m.group(1)} continue if current_station is None: continue # signal: -65 dBm (may be "signal: -65 [-65] dBm") sig_m = re.match(r"^signal:\s+([-\d]+)", stripped) if sig_m: try: current_station["signal"] = int(sig_m.group(1)) except ValueError: pass continue # tx bitrate: 54.0 MBit/s tx_m = re.match(r"^tx bitrate:\s+([\d.]+)", stripped) if tx_m: try: current_station["tx_rate"] = float(tx_m.group(1)) except ValueError: pass continue # rx bitrate: 72.2 MBit/s rx_m = re.match(r"^rx bitrate:\s+([\d.]+)", stripped) if rx_m: try: current_station["rx_rate"] = float(rx_m.group(1)) except ValueError: pass continue # connected time: 3600 seconds uptime_m = re.match(r"^connected time:\s+(\d+)", stripped) if uptime_m: try: current_station["uptime"] = int(uptime_m.group(1)) except ValueError: pass _flush() return results def get_channel_scan(self, mode: str = "active") -> dict[str, Any]: """Return channel scan results for all AP-mode interfaces. Uses ``iw dev scan`` (active) or ``iw dev scan passive`` (passive) on each AP interface. Keys are interface names (e.g. ``"wlan0"``). Values are lists of :class:`~napalm_device_types.models.ChannelScanEntryDict`. """ from napalm_device_types.models import ChannelScanEntryDict # Step 1: discover interfaces and filter to AP mode only iw_out = self._send_command("iw dev 2>/dev/null || true") ap_ifaces: list[str] = [] current_iface: str = "" current_type: str = "" iface_order: list[tuple[str, str]] = [] # (iface, type) for line in iw_out.splitlines(): stripped = line.strip() im = re.match(r"^Interface\s+(\S+)$", stripped) if im: if current_iface: iface_order.append((current_iface, current_type)) current_iface = im.group(1) current_type = "" continue tm = re.match(r"^type\s+(\S+)$", stripped) if tm and current_iface: current_type = tm.group(1) if current_iface: iface_order.append((current_iface, current_type)) ap_ifaces = [ name for name, itype in iface_order if itype.upper() in ("AP", "AP/VLAN") ] if not ap_ifaces: return {} # Step 2: run scan on each AP interface passive_flag = " passive" if mode == "passive" else "" result: dict[str, list[Any]] = {} for iface in ap_ifaces: scan_cmd = f"iw dev {iface} scan{passive_flag} 2>/dev/null || true" scan_out = self._send_command(scan_cmd) entries = self._parse_iw_scan(scan_out, iface) result[iface] = entries return result @staticmethod def _parse_iw_scan(scan_out: str, iface: str) -> list[Any]: """Parse ``iw dev scan`` output into ChannelScanEntryDict list.""" from napalm_device_types.models import ChannelScanEntryDict entries: list[Any] = [] current: dict[str, Any] = {} def _freq_to_band(freq: int) -> str: if freq < 3000: return "2.4GHz" if freq < 5950: return "5GHz" return "6GHz" def _freq_to_channel(freq: int) -> int: if 2412 <= freq <= 2484: if freq == 2484: return 14 return (freq - 2407) // 5 if 5160 <= freq <= 5885: return (freq - 5000) // 5 if 5955 <= freq <= 7115: return (freq - 5950) // 5 + 1 return 0 def _flush() -> None: if current.get("bssid"): freq = current.get("frequency", 0) ch = current.get("channel") or _freq_to_channel(freq) width = current.get("channel_width", 0) # If width is still 0 try to derive from HT/VHT/HE info if width == 0 and current.get("_ht_width"): width = current["_ht_width"] entries.append(ChannelScanEntryDict( bssid=current["bssid"], ssid=current.get("ssid", ""), frequency=freq, channel=ch, signal_dbm=current.get("signal_dbm", 0), channel_width=width, band=_freq_to_band(freq), )) for line in scan_out.splitlines(): stripped = line.strip() # New BSS block: "BSS aa:bb:cc:dd:ee:ff(on wlan0)" bss_m = re.match(r"^BSS\s+([\da-fA-F:]{17})\(", stripped) if bss_m: _flush() current = {"bssid": bss_m.group(1).lower()} continue # freq: 2437 freq_m = re.match(r"^freq:\s+(\d+)$", stripped) if freq_m: current["frequency"] = int(freq_m.group(1)) continue # signal: -72.00 dBm sig_m = re.match(r"^signal:\s+([-\d.]+)\s+dBm", stripped) if sig_m: current["signal_dbm"] = int(float(sig_m.group(1))) continue # SSID: NeighborNet (may be empty for hidden) ssid_m = re.match(r"^SSID:\s*(.*)?$", stripped) if ssid_m: current["ssid"] = ssid_m.group(1).strip() continue # HT operation primary channel: 6 ht_ch_m = re.match(r"^\*\s+primary channel:\s+(\d+)", stripped) if ht_ch_m: current["channel"] = int(ht_ch_m.group(1)) continue # HT STA channel width: 20 MHz ht_w_m = re.match(r"^\*\s+STA channel width:\s+(\d+)\s+MHz", stripped) if ht_w_m: current["_ht_width"] = int(ht_w_m.group(1)) continue # VHT channel width: 80 MHz vht_w_m = re.match(r"^\*\s+channel width:\s+(\d+)\s+MHz", stripped) if vht_w_m: current["channel_width"] = int(vht_w_m.group(1)) continue _flush() return entries def push_radio_channel(self, radio: str, channel: int) -> None: """Set a new channel on *radio* via UCI and restart the wireless stack. Uses ``wifi`` (full down/up cycle) rather than ``wifi reload``. ``wifi reload`` only re-applies the running config without changing the physical channel on many hardware/driver combinations; a full restart is required for channel changes to take effect. :param radio: UCI radio name, e.g. ``"radio0"``. :param channel: Channel number to set; ``0`` sets UCI ``auto``. """ ch_val = "auto" if channel == 0 else str(channel) self._send_command(f"uci set wireless.{radio}.channel={ch_val}") self._send_command("uci commit wireless") self._send_command("wifi") def push_mac_acl(self, ssid_name: str, mode: str, macs: list[str]) -> None: """Rewrite macfilter mode + maclist entries on every wifi-iface matching *ssid_name*. Full-rebuild, not diff — always deletes the existing maclist before re-adding, so the result is idempotent regardless of prior state. OpenWrt's wifi-scripts validator rejects any ``macfilter`` value other than ``"allow"``/``"deny"`` outright (confirmed on real hardware: setting ``macfilter='disable'`` puts netifd in a permanent restart crash loop with the radio stuck down) — there is no "off" value; the only way to disable filtering is to omit the option entirely. A whitelist ("allow") with zero MACs blocks every client outright, so it is treated as equivalent to "off" instead of being pushed as-is. :param ssid_name: SSID name to match against ``option ssid`` on each wifi-iface section. :param mode: ``"off"`` | ``"whitelist"`` | ``"blacklist"``. :param macs: MAC addresses to set as the maclist. Only the entries for the active mode's list are ever passed in — the caller resolves whitelist vs. blacklist before calling. """ effective_mode = "off" if (mode == "whitelist" and not macs) else mode sections = self._send_command( "uci show wireless | grep -oE '^wireless\\.[^.]+' | sort -u" ).split() for sec in sections: ssid_val = self._send_command(f"uci -q get {sec}.ssid 2>/dev/null || true").strip() if ssid_val != ssid_name: continue if effective_mode == "off": self._send_command(f"uci -q delete {sec}.macfilter || true") self._send_command(f"uci -q delete {sec}.maclist || true") continue uci_mode = "allow" if effective_mode == "whitelist" else "deny" self._send_command(f"uci set {sec}.macfilter='{uci_mode}'") self._send_command(f"uci delete {sec}.maclist 2>/dev/null || true") for mac in macs: self._send_command(f"uci add_list {sec}.maclist='{mac}'") self._send_command("uci commit wireless") self._send_command("wifi reload") def get_radio_status(self) -> dict[str, Any]: """Return radio status from UCI and iwinfo. Combines ``uci show wireless`` for static config with ``iwinfo`` output for runtime channel/frequency and tx-power data. Returns a dict keyed by radio name (e.g. ``"radio0"``) with: * enabled (bool) * band (str) — ``"2.4GHz"``, ``"5GHz"``, ``"6GHz"`` * channel (int) — 0 means auto * channel_width (int) — channel bandwidth in MHz (0 if unknown) * tx_power (int) — TX power in dBm (0 if unknown) * frequency (float) — centre frequency in MHz (0 if unknown) * htmode (str) — e.g. ``"HT20"``, ``"VHT80"``, ``"HE80"`` * country (str) — regulatory country code, e.g. ``"DE"`` """ from napalm_device_types.models import RadioStatusDict uci_out = self._send_command("uci show wireless") radios: dict[str, dict[str, str]] = {} for line in uci_out.splitlines(): # wifi-device section: wireless.radio0.band='2g' m = re.match(r"wireless\.(radio\d+)\.(\w+)='([^']*)'", line.strip()) if m: radio, key, value = m.group(1), m.group(2), m.group(3) radios.setdefault(radio, {})[key] = value result: dict[str, Any] = {} for radio, cfg in sorted(radios.items()): band_raw = cfg.get("band", cfg.get("hwmode", "")) # Normalise band: '2g'/'11g' → '2.4GHz', '5g'/'11a' → '5GHz', '6g' → '6GHz' if band_raw in ("2g", "11g", "b", "g", "bg", "bgn"): band = "2.4GHz" elif band_raw in ("5g", "11a", "a", "ac", "ax5"): band = "5GHz" elif band_raw in ("6g", "ax6"): band = "6GHz" else: band = band_raw or "unknown" try: channel = int(cfg.get("channel", 0)) except (ValueError, TypeError): channel = 0 # 'auto' try: tx_power = int(cfg.get("txpower", 0)) except (ValueError, TypeError): tx_power = 0 disabled = cfg.get("disabled", "0") == "1" htmode = cfg.get("htmode", "") country = cfg.get("country", "") # Derive channel_width from htmode string (e.g. VHT80 → 80 MHz) _HTMODE_WIDTH = { "HT20": 20, "HT40": 40, "VHT20": 20, "VHT40": 40, "VHT80": 80, "VHT80+80": 80, "VHT160": 160, "HE20": 20, "HE40": 40, "HE80": 80, "HE160": 160, "EHT20": 20, "EHT40": 40, "EHT80": 80, "EHT160": 160, "EHT320": 320, } channel_width = _HTMODE_WIDTH.get(htmode.upper(), 0) result[radio] = { **RadioStatusDict( enabled=not disabled, band=band, channel=channel, channel_width=channel_width, tx_power=tx_power, frequency=0.0, # enriched below via iwinfo ), "htmode": htmode, "country": country, } # Enrich with iwinfo runtime data (channel, frequency, tx_power, channel_width) # iwinfo groups output per interface; we need to map interface → radio. # "phy0-ap0 ESSID: "MyNet"" → radio0 # " Tx-Power: 23 dBm" # " Channel: 44 (5.220 GHz), Width: 80 MHz" try: iwinfo_out = self._send_command("iwinfo 2>/dev/null || true") except Exception: iwinfo_out = "" current_radio: str | None = None for line in iwinfo_out.splitlines(): # Interface header line: "phy0-ap0 ESSID: ..." iface_m = re.match(r"^(\S+)\s+ESSID:", line) if iface_m: iface_name = iface_m.group(1) phy_m = re.match(r"^phy(\d+)", iface_name) if phy_m: current_radio = f"radio{phy_m.group(1)}" else: current_radio = None continue if current_radio is None or current_radio not in result: continue # Channel and frequency: "Channel: 44 (5.220 GHz), Width: 80 MHz" ch_m = re.search(r"Channel:\s+(\d+)\s+\(([\d.]+)\s+GHz\)", line) if ch_m: result[current_radio]["channel"] = int(ch_m.group(1)) result[current_radio]["frequency"] = float(ch_m.group(2)) * 1000 # Width (MHz): "Width: 80 MHz" or ", Width: 80 MHz" width_m = re.search(r"Width:\s+(\d+)\s+MHz", line) if width_m: result[current_radio]["channel_width"] = int(width_m.group(1)) # Tx-Power: "Tx-Power: 23 dBm" pwr_m = re.search(r"Tx-Power:\s+(\d+)\s+dBm", line) if pwr_m: result[current_radio]["tx_power"] = int(pwr_m.group(1)) return result