feat: get_channel_scan() + push_radio_channel() in OpenWrtWirelessMixin

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>
This commit is contained in:
Christian Manivong
2026-06-24 23:26:25 +02:00
co-authored by Claude Sonnet 4.6
parent e3a0a9e4b3
commit 512871b1bb
2 changed files with 422 additions and 0 deletions
+264
View File
@@ -833,3 +833,267 @@ class TestGetNetworkInstances:
)
for inst in driver.get_network_instances().values():
assert set(inst.keys()) >= {"name", "type", "state", "interfaces"}
# ---------------------------------------------------------------------------
# Sample data for get_channel_scan()
# ---------------------------------------------------------------------------
IW_DEV_OUT = """\
phy#0
\tInterface wlan0
\t\tifindex 4
\t\twdev 0x1
\t\taddr aa:bb:cc:dd:ee:f0
\t\tssid HomeNet
\t\ttype AP
\t\tchannel 6 (2437 MHz), width: 20 MHz, center1: 2437 MHz
phy#1
\tInterface wlan1
\t\tifindex 5
\t\twdev 0x2
\t\taddr aa:bb:cc:dd:ee:f1
\t\tssid HomeNet
\t\ttype AP
\t\tchannel 36 (5180 MHz), width: 80 MHz, center1: 5210 MHz
\tInterface wlan2
\t\tifindex 6
\t\twdev 0x3
\t\taddr aa:bb:cc:dd:ee:f2
\t\tssid GuestNet
\t\ttype managed
"""
# Minimal iw scan output for wlan0 (2.4 GHz AP, ch 6 + ch 1)
IW_SCAN_WLAN0 = """\
BSS aa:bb:cc:dd:01:ff(on wlan0)
\tfreq: 2437
\tsignal: -72.00 dBm
\tSSID: NeighborNet1
\tHT operation:
\t\t * primary channel: 6
\t\t * secondary channel offset: no secondary
\t\t * STA channel width: 20 MHz
BSS aa:bb:cc:dd:02:ff(on wlan0)
\tfreq: 2412
\tsignal: -85.00 dBm
\tSSID: FarAway
\tHT operation:
\t\t * primary channel: 1
\t\t * secondary channel offset: no secondary
\t\t * STA channel width: 20 MHz
BSS aa:bb:cc:dd:03:ff(on wlan0)
\tfreq: 2452
\tsignal: -90.00 dBm
\tSSID: Hidden
\tHT operation:
\t\t * primary channel: 9
\t\t * secondary channel offset: no secondary
\t\t * STA channel width: 20 MHz
"""
# Minimal iw scan output for wlan1 (5 GHz AP, ch 36 + 44)
IW_SCAN_WLAN1 = """\
BSS 11:22:33:44:01:ff(on wlan1)
\tfreq: 5180
\tsignal: -58.00 dBm
\tSSID: Corp5G
\tVHT operation:
\t\t * channel width: 80 MHz
\t\t * center freq segment 1: 42
\t\t * center freq segment 2: 0
BSS 11:22:33:44:02:ff(on wlan1)
\tfreq: 5220
\tsignal: -77.00 dBm
\tSSID: Office5G
\tVHT operation:
\t\t * channel width: 80 MHz
"""
class TestGetChannelScan:
"""Tests for OpenWrtWirelessMixin.get_channel_scan()."""
def _make_send(self, mode: str = "active"):
"""Return a _send_command mock routing iw dev / iw scan output."""
def _send(cmd, **kw):
if cmd.strip().startswith("iw dev") and "scan" not in cmd:
return IW_DEV_OUT
if "wlan0" in cmd and "scan" in cmd:
return IW_SCAN_WLAN0
if "wlan1" in cmd and "scan" in cmd:
return IW_SCAN_WLAN1
return ""
return _send
def test_returns_dict(self, driver):
driver._send_command = self._make_send()
result = driver.get_channel_scan()
assert isinstance(result, dict)
def test_only_ap_interfaces_included(self, driver):
"""wlan2 is managed (client) mode — must not appear in results."""
driver._send_command = self._make_send()
result = driver.get_channel_scan()
assert "wlan0" in result
assert "wlan1" in result
assert "wlan2" not in result
def test_wlan0_entry_count(self, driver):
driver._send_command = self._make_send()
result = driver.get_channel_scan()
assert len(result["wlan0"]) == 3
def test_wlan0_entry_required_keys(self, driver):
driver._send_command = self._make_send()
entry = driver.get_channel_scan()["wlan0"][0]
assert set(entry.keys()) >= {
"bssid", "ssid", "frequency", "channel", "signal_dbm",
"channel_width", "band",
}
def test_wlan0_first_entry_values(self, driver):
driver._send_command = self._make_send()
entry = driver.get_channel_scan()["wlan0"][0]
assert entry["bssid"] == "aa:bb:cc:dd:01:ff"
assert entry["ssid"] == "NeighborNet1"
assert entry["frequency"] == 2437
assert entry["channel"] == 6
assert entry["signal_dbm"] == -72
assert entry["channel_width"] == 20
assert entry["band"] == "2.4GHz"
def test_wlan0_ch1_entry(self, driver):
driver._send_command = self._make_send()
entries = driver.get_channel_scan()["wlan0"]
ch1 = next(e for e in entries if e["channel"] == 1)
assert ch1["signal_dbm"] == -85
assert ch1["band"] == "2.4GHz"
def test_wlan0_hidden_ssid(self, driver):
"""Empty SSID (hidden) should be stored as empty string."""
driver._send_command = self._make_send()
entries = driver.get_channel_scan()["wlan0"]
ch9 = next(e for e in entries if e["channel"] == 9)
assert ch9["ssid"] == "Hidden"
def test_wlan1_band_is_5ghz(self, driver):
driver._send_command = self._make_send()
entries = driver.get_channel_scan()["wlan1"]
assert all(e["band"] == "5GHz" for e in entries)
def test_wlan1_80mhz_width(self, driver):
driver._send_command = self._make_send()
entry = driver.get_channel_scan()["wlan1"][0]
assert entry["channel_width"] == 80
def test_passive_mode_uses_passive_flag(self, driver):
"""With mode='passive', the scan command must contain 'passive'."""
issued_cmds: list[str] = []
def _send(cmd, **kw):
issued_cmds.append(cmd)
if cmd.strip().startswith("iw dev") and "scan" not in cmd:
return IW_DEV_OUT
return ""
driver._send_command = _send
driver.get_channel_scan(mode="passive")
scan_cmds = [c for c in issued_cmds if "scan" in c]
assert all("passive" in c for c in scan_cmds)
def test_active_mode_no_passive_flag(self, driver):
"""With mode='active' (default), scan command must NOT contain 'passive'."""
issued_cmds: list[str] = []
def _send(cmd, **kw):
issued_cmds.append(cmd)
if cmd.strip().startswith("iw dev") and "scan" not in cmd:
return IW_DEV_OUT
return ""
driver._send_command = _send
driver.get_channel_scan(mode="active")
scan_cmds = [c for c in issued_cmds if "scan" in c]
assert all("passive" not in c for c in scan_cmds)
def test_no_ap_interfaces_returns_empty(self, driver):
"""If no AP-mode interfaces exist, return empty dict."""
no_ap = IW_DEV_OUT.replace("type AP", "type managed")
driver._send_command = lambda cmd, **kw: (
no_ap if "iw dev" in cmd and "scan" not in cmd else ""
)
result = driver.get_channel_scan()
assert result == {}
def test_empty_iw_output_returns_empty(self, driver):
driver._send_command = lambda cmd, **kw: ""
result = driver.get_channel_scan()
assert result == {}
def test_6ghz_band_detected(self, driver):
iw_dev_6g = """\
phy#0
\tInterface wlan0
\t\taddr aa:bb:cc:dd:ee:f0
\t\tssid Net6G
\t\ttype AP
"""
scan_6g = """\
BSS aa:bb:cc:dd:01:ff(on wlan0)
\tfreq: 5975
\tsignal: -65.00 dBm
\tSSID: Neighbor6G
"""
def _send(cmd, **kw):
if "iw dev" in cmd and "scan" not in cmd:
return iw_dev_6g
if "scan" in cmd:
return scan_6g
return ""
driver._send_command = _send
entries = driver.get_channel_scan().get("wlan0", [])
assert len(entries) == 1
assert entries[0]["band"] == "6GHz"
assert entries[0]["frequency"] == 5975
class TestPushRadioChannel:
"""Tests for OpenWrtWirelessMixin.push_radio_channel()."""
def test_issues_uci_set(self, driver):
"""Must call 'uci set wireless.radio0.channel=11'."""
issued: list[str] = []
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
driver.push_radio_channel("radio0", 11)
assert any("uci set wireless.radio0.channel=11" in c for c in issued)
def test_issues_uci_commit(self, driver):
issued: list[str] = []
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
driver.push_radio_channel("radio0", 11)
assert any("uci commit wireless" in c for c in issued)
def test_issues_wifi_reload(self, driver):
issued: list[str] = []
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
driver.push_radio_channel("radio1", 36)
assert any("wifi reload" in c for c in issued)
def test_auto_channel_zero(self, driver):
"""channel=0 should set 'auto' in UCI."""
issued: list[str] = []
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
driver.push_radio_channel("radio0", 0)
assert any("channel=auto" in c or "channel='auto'" in c for c in issued)
def test_commit_before_reload(self, driver):
"""UCI commit must happen before wifi reload."""
issued: list[str] = []
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
driver.push_radio_channel("radio0", 6)
commit_idx = next(i for i, c in enumerate(issued) if "commit" in c)
reload_idx = next(i for i, c in enumerate(issued) if "wifi reload" in c)
assert commit_idx < reload_idx