get_channel_scan(mode='active'|'passive'): - Discovers AP-mode interfaces via 'iw dev' - Runs 'iw dev <iface> scan [passive]' on each AP interface - Parses BSS blocks: BSSID, SSID, frequency, channel, signal, channel width (HT/VHT/HE), band (2.4/5/6 GHz) - Returns dict[iface → list[ChannelScanEntryDict]] push_radio_channel(radio, channel): - Sets channel via 'uci set wireless.<radio>.channel=<ch|auto>' - Commits and reloads: 'uci commit wireless && wifi reload' - channel=0 writes 'auto' Tests: 19 new unit tests covering parse logic, active/passive flag, AP-only interface filtering, 6 GHz band detection, push command order. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
662 lines
27 KiB
Python
662 lines
27 KiB
Python
# -*- 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]] = {}
|
|
|
|
# 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)
|
|
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)
|
|
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]] = {}
|
|
for entry in iface_entries.values():
|
|
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 ""
|
|
|
|
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
|
|
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,
|
|
}
|
|
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 <iface> 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 <iface> scan`` (active) or
|
|
``iw dev <iface> 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 <iface> 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 reload the wireless stack.
|
|
|
|
: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 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
|