controller: selectable hotspot protocol (WPA2/WPA3) with device capability probing

Probe the selected WiFi device with iw to determine which hotspot
protocols it can host: WPA2 requires AP mode; WPA3 requires SAE in AP
mode (SAE_OFFLOAD_AP, or a mac80211 driver with the CMAC cipher for
software SAE).  The Raspberry Pi's brcmfmac supports neither, so Pis
correctly offer WPA2 only (verified against real Pi 4/Pi 5 iw output).

The protocol is saved as hotspot.protocol in controller.yaml.
enable_hotspot() now always sets the Hotspot connection's key-mgmt
explicitly (sae + required PMF for WPA3) so a reused profile cannot
keep a stale protocol, and reverts to WPA2 if the driver rejects SAE.

New GET /api/hotspot/protocols endpoint; protocol dropdowns added to
both the ControllerPage React UI and the fallback controller UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-28 19:52:39 -06:00 • committed by lrnselfreliance
parent 2355095391
commit 8687816b84
10 changed files with 545 additions and 16 deletions

View file

@ -9,6 +9,7 @@ from controller.api.schemas import (
BluetoothStatusResponse,
HotspotActionResponse,
HotspotDevicesResponse,
HotspotProtocolsResponse,
HotspotSettingsRequest,
HotspotSettingsResponse,
HotspotStatusResponse,
@ -33,8 +34,10 @@ from controller.lib.admin import (
enable_hotspot,
enable_throttle,
get_bluetooth_status_dict,
get_device_hotspot_protocols,
get_hotspot_device,
get_hotspot_password,
get_hotspot_protocol,
get_hotspot_ssid,
get_hotspot_status_dict,
get_throttle_status_dict,
@ -70,6 +73,13 @@ async def hotspot_devices() -> HotspotDevicesResponse:
return HotspotDevicesResponse(devices=get_wifi_devices())
@router.get("/api/hotspot/protocols", response_model=HotspotProtocolsResponse)
async def hotspot_protocols(device: str = None) -> HotspotProtocolsResponse:
"""List the hotspot protocols a WiFi device can host; defaults to the configured device."""
device = device or get_hotspot_device()
return HotspotProtocolsResponse(device=device, protocols=get_device_hotspot_protocols(device))
@router.get("/api/hotspot/settings", response_model=HotspotSettingsResponse)
async def hotspot_settings() -> HotspotSettingsResponse:
"""Get WiFi hotspot settings."""
@ -77,6 +87,7 @@ async def hotspot_settings() -> HotspotSettingsResponse:
device=get_hotspot_device(),
ssid=get_hotspot_ssid(),
password=get_hotspot_password(),
protocol=get_hotspot_protocol(),
)
@ -86,10 +97,12 @@ async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSet
# 500 matches the other subsystem endpoints (hotspot/bluetooth/throttle) in Docker mode.
if is_docker_mode():
raise HTTPException(status_code=500, detail="Not available in Docker mode")
result = update_hotspot_settings(device=request.device, ssid=request.ssid, password=request.password)
result = update_hotspot_settings(device=request.device, ssid=request.ssid, password=request.password,
protocol=request.protocol)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
return HotspotSettingsResponse(device=result["device"], ssid=result["ssid"], password=result["password"])
return HotspotSettingsResponse(device=result["device"], ssid=result["ssid"], password=result["password"],
protocol=result["protocol"])
@router.post("/api/hotspot/enable", response_model=HotspotActionResponse)

View file

@ -164,12 +164,20 @@ class HotspotDevicesResponse(BaseModel):
devices: list[str] = Field(description="Available WiFi device names")
class HotspotProtocolsResponse(BaseModel):
"""Response model for /api/hotspot/protocols endpoint."""
device: str = Field(description="WiFi device name that was probed")
protocols: list[str] = Field(description="Hotspot protocols the device can host (wpa2, wpa3)")
class HotspotSettingsRequest(BaseModel):
"""Request model for updating hotspot settings; only provided values are changed."""
device: Optional[str] = Field(default=None, description="WiFi device name")
ssid: Optional[str] = Field(default=None, description="Hotspot SSID")
password: Optional[str] = Field(default=None, description="Hotspot password (8 to 63 characters)")
protocol: Optional[str] = Field(default=None, description="Hotspot protocol (wpa2 or wpa3)")
class HotspotSettingsResponse(BaseModel):
@ -178,6 +186,7 @@ class HotspotSettingsResponse(BaseModel):
device: str = Field(description="WiFi device name")
ssid: str = Field(description="Hotspot SSID")
password: str = Field(description="Hotspot password")
protocol: str = Field(description="Hotspot protocol (wpa2 or wpa3)")
class HotspotActionResponse(BaseModel):

View file

@ -121,6 +121,7 @@ DEFAULT_CONFIG = {
"device": "wlan0",
"ssid": "WROLPi",
"password": "wrolpi hotspot",
"protocol": "wpa2", # wpa2 (wpa-psk) or wpa3 (sae); see get_device_hotspot_protocols
},
"throttle": {

View file

@ -58,7 +58,95 @@ def get_hotspot_password() -> str:
return get_config_value('hotspot.password', 'wrolpi hotspot')
def update_hotspot_settings(device: str = None, ssid: str = None, password: str = None) -> dict:
# WPA2 uses nmcli's default `wpa-psk` key management; WPA3 uses `sae`.
HOTSPOT_PROTOCOLS = ('wpa2', 'wpa3')
def get_hotspot_protocol() -> str:
return get_config_value('hotspot.protocol', 'wpa2')
def get_device_hotspot_protocols(device: str) -> list[str]:
"""
Probe a WiFi device with `iw` and report which hotspot protocols it can host.
WPA2 only requires AP mode. WPA3 requires SAE in AP mode, which is only possible
when the firmware offloads SAE for APs (SAE_OFFLOAD_AP), or the driver is
mac80211-based (advertises AP/VLAN) with the CMAC cipher so wpa_supplicant can run
SAE in software. Notably the Raspberry Pi's brcmfmac driver supports neither.
"""
if is_docker_mode():
return []
try:
# Absolute path like rfkill; `iw` is in /usr/sbin which is not on every PATH.
result = subprocess.run(
["/usr/sbin/iw", "dev", device, "info"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return []
wiphy = None
for line in result.stdout.splitlines():
stripped = line.strip()
if stripped.startswith("wiphy "):
wiphy = stripped.split()[1]
break
if wiphy is None:
return []
result = subprocess.run(
["/usr/sbin/iw", "phy", f"phy{wiphy}", "info"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return []
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
# Cannot probe without `iw`; assume the WPA2 hotspot nmcli has always created.
return ['wpa2']
modes, ciphers, ext_features = set(), set(), set()
section = None
for line in result.stdout.splitlines():
stripped = line.strip()
if stripped.startswith('Supported interface modes:'):
section = 'modes'
continue
elif stripped.startswith('Supported Ciphers:'):
section = 'ciphers'
continue
elif stripped.startswith(('Supported extended features:', 'Extended features:')):
# Real Pi hardware prints "Supported extended features:"; older iw
# releases print "Extended features:".
section = 'ext'
continue
if not stripped.startswith('*'):
section = None
continue
value = stripped.lstrip('* ').strip()
if section == 'modes':
modes.add(value)
elif section == 'ciphers':
ciphers.add(value.split()[0])
elif section == 'ext' and '[' in stripped and ']' in stripped:
ext_features.add(stripped.split('[', 1)[1].split(']', 1)[0].strip())
protocols = []
if 'AP' in modes:
protocols.append('wpa2')
sae_offload_ap = 'SAE_OFFLOAD_AP' in ext_features
software_sae = 'AP/VLAN' in modes and any('CMAC' in cipher for cipher in ciphers)
if sae_offload_ap or software_sae:
protocols.append('wpa3')
return protocols
def update_hotspot_settings(device: str = None, ssid: str = None, password: str = None,
protocol: str = None) -> dict:
"""
Update hotspot settings in controller.yaml. Only provided values are changed.
@ -75,6 +163,8 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str
# nmcli requires a WPA password of 8 to 63 characters.
if password is not None and not 8 <= len(password) <= 63:
return {"success": False, "error": "Hotspot password must be 8 to 63 characters"}
if protocol is not None and protocol not in HOTSPOT_PROTOCOLS:
return {"success": False, "error": f"Hotspot protocol must be one of: {', '.join(HOTSPOT_PROTOCOLS)}"}
changed = False
if device:
@ -86,6 +176,9 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str
if password:
config_lib.update_config('hotspot.password', password)
changed = True
if protocol:
config_lib.update_config('hotspot.protocol', protocol)
changed = True
if changed:
try:
@ -93,12 +186,14 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str
except RuntimeError as e:
return {"success": False, "error": str(e)}
logger.info("Hotspot settings updated (device=%s, ssid=%s)", get_hotspot_device(), get_hotspot_ssid())
logger.info("Hotspot settings updated (device=%s, ssid=%s, protocol=%s)",
get_hotspot_device(), get_hotspot_ssid(), get_hotspot_protocol())
return {
"success": True,
"device": get_hotspot_device(),
"ssid": get_hotspot_ssid(),
"password": get_hotspot_password(),
"protocol": get_hotspot_protocol(),
}
@ -299,6 +394,7 @@ def enable_hotspot() -> dict:
device = get_hotspot_device()
ssid = get_hotspot_ssid()
password = get_hotspot_password()
protocol = get_hotspot_protocol()
try:
# First ensure radio is on
@ -349,13 +445,55 @@ def enable_hotspot() -> dict:
timeout=30,
)
if result.returncode == 0:
logger.info("Hotspot enabled successfully on %s", device)
return {"success": True, "ssid": ssid, "device": device}
else:
if result.returncode != 0:
logger.warning("Failed to enable hotspot: %s", result.stderr.strip())
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
# Explicitly set the key management so a reused "Hotspot" profile does not
# keep a previously configured protocol. WPA3 (SAE) requires PMF.
key_mgmt = "sae" if protocol == "wpa3" else "wpa-psk"
pmf = "3" if protocol == "wpa3" else "0" # 3=required, 0=default
modify = subprocess.run(
["nmcli", "connection", "modify", "Hotspot",
"802-11-wireless-security.key-mgmt", key_mgmt,
"802-11-wireless-security.pmf", pmf],
capture_output=True,
text=True,
timeout=10,
)
up = None
if modify.returncode == 0:
# Re-activate so the profile change takes effect.
up = subprocess.run(
["nmcli", "connection", "up", "Hotspot"],
capture_output=True,
text=True,
timeout=30,
)
if modify.returncode != 0 or up.returncode != 0:
error = (modify.stderr if modify.returncode != 0 else up.stderr).strip()
if protocol == "wpa3":
# The driver likely rejected SAE; revert so the hotspot still works.
logger.warning("Failed to enable WPA3 hotspot, reverting to WPA2: %s", error)
subprocess.run(
["nmcli", "connection", "modify", "Hotspot",
"802-11-wireless-security.key-mgmt", "wpa-psk",
"802-11-wireless-security.pmf", "0"],
capture_output=True, text=True, timeout=10,
)
subprocess.run(
["nmcli", "connection", "up", "Hotspot"],
capture_output=True, text=True, timeout=30,
)
return {"success": False,
"error": f"WPA3 is not supported by {device}; hotspot reverted to WPA2: {error}"}
# WPA2 is what nmcli created the hotspot with anyway; not fatal.
logger.warning("Failed to explicitly set WPA2 on hotspot: %s", error)
logger.info("Hotspot enabled successfully on %s (%s)", device, protocol)
return {"success": True, "ssid": ssid, "device": device}
except subprocess.TimeoutExpired:
logger.warning("Timeout enabling hotspot on %s", device)
return {"success": False, "error": "Timeout enabling hotspot"}

View file

@ -985,7 +985,12 @@
<input type="text" id="hotspot-password">
</label>
<label>Device
<select id="hotspot-device"
<select id="hotspot-device" onchange="loadHotspotProtocols()"
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
</select>
</label>
<label>Protocol
<select id="hotspot-protocol"
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
</select>
</label>
@ -1442,11 +1447,38 @@
option.selected = name === settings.device;
select.appendChild(option);
}
await loadHotspotProtocols(settings.protocol);
} catch (e) {
console.error('Failed to load hotspot settings', e);
}
}
// Human labels for the hotspot protocols reported by the Controller.
const hotspotProtocolLabels = {wpa2: 'WPA2 (most compatible)', wpa3: 'WPA3'};
async function loadHotspotProtocols(savedProtocol) {
// The supported protocols depend on the selected device's hardware.
const select = document.getElementById('hotspot-protocol');
const device = document.getElementById('hotspot-device').value;
if (!select || !device) return;
savedProtocol = savedProtocol || select.value;
try {
const response = await apiCall(`api/hotspot/protocols?device=${encodeURIComponent(device)}`);
select.innerHTML = '';
// Keep the saved protocol selectable even if the device does not report it.
const protocols = [...new Set([...(response.protocols || []), savedProtocol])].filter(Boolean);
for (const protocol of protocols) {
const option = document.createElement('option');
option.value = protocol;
option.textContent = hotspotProtocolLabels[protocol] || protocol;
option.selected = protocol === savedProtocol;
select.appendChild(option);
}
} catch (e) {
console.error('Failed to load hotspot protocols', e);
}
}
async function saveHotspotSettings() {
const btn = event.target;
btn.disabled = true;
@ -1455,6 +1487,7 @@
ssid: document.getElementById('hotspot-ssid').value,
password: document.getElementById('hotspot-password').value,
device: document.getElementById('hotspot-device').value,
protocol: document.getElementById('hotspot-protocol').value || null,
});
await updateHotspotButton();
} catch (e) {

View file

@ -21,8 +21,10 @@ from controller.lib.admin import (
get_hotspot_status_dict,
get_throttle_status,
get_timezone_status_dict,
get_device_hotspot_protocols,
get_hotspot_device,
get_hotspot_password,
get_hotspot_protocol,
get_hotspot_ssid,
get_wifi_devices,
migrate_hotspot_settings_from_wrolpi_config,
@ -387,6 +389,26 @@ class TestUpdateHotspotSettings:
assert result["ssid"] == 'WROLPi'
assert result["password"] == 'wrolpi hotspot'
def test_updates_protocol(self, mock_config_path, reset_runtime_config):
"""The protocol is saved to controller.yaml and returned."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
result = update_hotspot_settings(protocol='wpa3')
assert result["success"] is True
assert result["protocol"] == 'wpa3'
assert get_hotspot_protocol() == 'wpa3'
saved = yaml.safe_load(mock_config_path.read_text())
assert saved['hotspot']['protocol'] == 'wpa3'
def test_rejects_invalid_protocol(self, mock_config_path, reset_runtime_config):
"""Only wpa2 and wpa3 are valid protocols."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
result = update_hotspot_settings(protocol='wep')
assert result["success"] is False
assert "protocol" in result["error"].lower()
assert get_hotspot_protocol() == 'wpa2'
def test_rejects_short_password(self, mock_config_path, reset_runtime_config):
"""nmcli requires a WPA password of 8 to 63 characters."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
@ -426,6 +448,134 @@ class TestUpdateHotspotSettings:
assert "not mounted" in result["error"]
# `iw dev wlan0 info` output; only the "wiphy" line matters.
IW_DEV_INFO = """Interface wlan0
\tifindex 3
\twdev 0x1
\taddr d8:3a:dd:11:22:33
\ttype managed
\twiphy 0
\ttxpower 31.00 dBm
"""
# Raspberry Pi brcmfmac (verified on real Pi 4/Pi 5 hardware): AP mode, but no
# AP/VLAN (fullmac driver) and no AP-mode SAE, so WPA3 hotspot is not possible.
# "SAE with AUTHENTICATE command" is station-mode WPA3 (joining, not hosting).
IW_PHY_BRCMFMAC = """Wiphy phy0
\twiphy index: 0
\tmax # scan SSIDs: 10
\tSupported Ciphers:
\t\t* WEP40 (00-0f-ac:1)
\t\t* WEP104 (00-0f-ac:5)
\t\t* TKIP (00-0f-ac:2)
\t\t* CCMP-128 (00-0f-ac:4)
\t\t* CMAC (00-0f-ac:6)
\tAvailable Antennas: TX 0 RX 0
\tSupported interface modes:
\t\t * IBSS
\t\t * managed
\t\t * AP
\t\t * P2P-client
\t\t * P2P-GO
\t\t * P2P-device
\tDevice supports SAE with AUTHENTICATE command
\tSupported extended features:
\t\t* [ CQM_RSSI_LIST ]: multiple CQM_RSSI_THOLD records
\t\t* [ DFS_OFFLOAD ]: DFS offload
"""
# mac80211 driver (Intel/Atheros style): AP/VLAN plus the CMAC cipher means
# wpa_supplicant can run SAE in software for the AP.
IW_PHY_MAC80211 = """Wiphy phy0
\tmax # scan SSIDs: 4
\tSupported Ciphers:
\t\t* WEP40 (00-0f-ac:1)
\t\t* TKIP (00-0f-ac:2)
\t\t* CCMP-128 (00-0f-ac:4)
\t\t* CMAC (00-0f-ac:6)
\tSupported interface modes:
\t\t * IBSS
\t\t * managed
\t\t * AP
\t\t * AP/VLAN
\t\t * monitor
\t\t * mesh point
\tSupported extended features:
\t\t* [ RRM ]: RRM
\t\t* [ FILS_STA ]: FILS shared key authentication support
"""
# Fullmac driver with AP-mode SAE offload advertised by the firmware.
# Older iw releases print "Extended features:" instead of "Supported extended features:".
IW_PHY_SAE_OFFLOAD_AP = """Wiphy phy0
\tSupported Ciphers:
\t\t* CCMP-128 (00-0f-ac:4)
\tSupported interface modes:
\t\t * managed
\t\t * AP
\tExtended features:
\t\t* [ SAE_OFFLOAD ]: SAE offload support
\t\t* [ SAE_OFFLOAD_AP ]: SAE offload support (AP mode)
"""
# Station-only hardware which cannot host a hotspot at all.
IW_PHY_NO_AP = """Wiphy phy0
\tSupported Ciphers:
\t\t* CCMP-128 (00-0f-ac:4)
\tSupported interface modes:
\t\t * managed
\t\t * monitor
"""
class TestGetDeviceHotspotProtocols:
"""Tests for get_device_hotspot_protocols; WPA3 requires SAE support in AP mode."""
@staticmethod
def _protocols(phy_info: str) -> list:
dev_info = mock.Mock(returncode=0, stdout=IW_DEV_INFO, stderr="")
phy = mock.Mock(returncode=0, stdout=phy_info, stderr="")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=[dev_info, phy]) as mock_run:
result = get_device_hotspot_protocols("wlan0")
# The wiphy index from `iw dev` is used to query the phy.
assert mock_run.call_args_list[1][0][0] == ["/usr/sbin/iw", "phy", "phy0", "info"]
return result
def test_rpi_brcmfmac_is_wpa2_only(self):
"""The Raspberry Pi's fullmac driver cannot host a WPA3 (SAE) AP."""
assert self._protocols(IW_PHY_BRCMFMAC) == ["wpa2"]
def test_mac80211_driver_supports_wpa3(self):
"""mac80211 drivers (AP/VLAN listed) with the CMAC cipher can run software SAE."""
assert self._protocols(IW_PHY_MAC80211) == ["wpa2", "wpa3"]
def test_sae_offload_ap_supports_wpa3(self):
"""Firmware advertising SAE_OFFLOAD_AP can host a WPA3 AP."""
assert self._protocols(IW_PHY_SAE_OFFLOAD_AP) == ["wpa2", "wpa3"]
def test_station_only_hardware_has_no_protocols(self):
"""A device without AP mode cannot host a hotspot at all."""
assert self._protocols(IW_PHY_NO_AP) == []
def test_returns_empty_in_docker_mode(self):
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
assert get_device_hotspot_protocols("wlan0") == []
def test_unknown_device_has_no_protocols(self):
"""`iw dev` fails for a device which does not exist."""
dev_info = mock.Mock(returncode=237, stdout="", stderr="command failed: No such device")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=[dev_info]):
assert get_device_hotspot_protocols("wlan9") == []
def test_assumes_wpa2_when_iw_missing(self):
"""Without `iw` we cannot probe; assume the WPA2 hotspot nmcli has always created."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
assert get_device_hotspot_protocols("wlan0") == ["wpa2"]
class TestGetWifiDevices:
"""Tests for get_wifi_devices function."""
@ -514,9 +664,10 @@ class TestEnableHotspot:
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
radio_on = mock.Mock(returncode=0, stdout="", stderr="")
device_ready = mock.Mock(returncode=0, stdout="wlan1:disconnected\n", stderr="")
hotspot_ok = mock.Mock(returncode=0, stdout="", stderr="")
ok = mock.Mock(returncode=0, stdout="", stderr="")
with mock.patch("subprocess.run", side_effect=[radio_on, device_ready, hotspot_ok]) as mock_subprocess:
with mock.patch("subprocess.run",
side_effect=[radio_on, device_ready, ok, ok, ok]) as mock_subprocess:
with mock.patch("time.sleep"):
enable_hotspot()
@ -529,6 +680,64 @@ class TestEnableHotspot:
assert "TestSSID" in cmd_args
assert "testpassword123" in cmd_args
@staticmethod
def _run_enable(protocol: str, results: list) -> tuple:
"""Run enable_hotspot() with the given hotspot.protocol and subprocess results."""
def mock_get_config(key, default=None):
return {'hotspot.protocol': protocol}.get(key, default)
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
with mock.patch("subprocess.run", side_effect=results) as mock_subprocess:
with mock.patch("time.sleep"):
result = enable_hotspot()
return result, mock_subprocess.call_args_list
def test_wpa2_sets_psk_key_mgmt(self):
"""The Hotspot connection is explicitly set to WPA2 so a reused profile
does not keep a previously configured protocol."""
ok = mock.Mock(returncode=0, stdout="", stderr="")
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
result, calls = self._run_enable('wpa2', [ok, device_ready, ok, ok, ok])
assert result["success"] is True
modify_call = [c[0][0] for c in calls if 'modify' in c[0][0]]
assert modify_call and "wpa-psk" in modify_call[0]
def test_wpa3_sets_sae_key_mgmt(self):
"""WPA3 modifies the Hotspot connection to SAE with required PMF, then re-activates."""
ok = mock.Mock(returncode=0, stdout="", stderr="")
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
result, calls = self._run_enable('wpa3', [ok, device_ready, ok, ok, ok])
assert result["success"] is True
modify_call = [c[0][0] for c in calls if 'modify' in c[0][0]]
assert modify_call and "sae" in modify_call[0]
assert "802-11-wireless-security.pmf" in modify_call[0]
up_call = [c[0][0] for c in calls if 'up' in c[0][0]]
assert up_call, "Hotspot connection was not re-activated"
def test_wpa3_failure_reverts_to_wpa2(self):
"""If the driver rejects WPA3, the hotspot is reverted to WPA2 and an error returned."""
ok = mock.Mock(returncode=0, stdout="", stderr="")
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
sae_failed = mock.Mock(returncode=1, stdout="", stderr="Error: SAE not supported")
# radio, poll, hotspot, modify(sae) fails, revert modify, revert up.
result, calls = self._run_enable('wpa3', [ok, device_ready, ok, sae_failed, ok, ok])
assert result["success"] is False
assert "wpa3" in result["error"].lower()
modify_calls = [c[0][0] for c in calls if 'modify' in c[0][0]]
assert len(modify_calls) == 2 and "wpa-psk" in modify_calls[1]
def test_wpa2_modify_failure_still_succeeds(self):
"""A failure setting WPA2 explicitly is not fatal; nmcli already created a WPA2 hotspot."""
ok = mock.Mock(returncode=0, stdout="", stderr="")
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
failed = mock.Mock(returncode=1, stdout="", stderr="Error: unknown property")
result, _ = self._run_enable('wpa2', [ok, device_ready, ok, failed])
assert result["success"] is True
def test_waits_for_device_ready_after_radio_on(self):
"""Should poll device status after turning radio on, waiting for device
@ -546,6 +755,8 @@ class TestEnableHotspot:
device_unavailable, # nmcli -t device status (not ready)
device_ready, # nmcli -t device status (ready)
hotspot_result, # nmcli device wifi hotspot
hotspot_result, # nmcli connection modify Hotspot
hotspot_result, # nmcli connection up Hotspot
]):
with mock.patch("time.sleep"): # Don't actually sleep in tests
result = enable_hotspot()

View file

@ -52,6 +52,34 @@ class TestHotspotDevicesEndpoint:
assert response.json() == {"devices": []}
class TestHotspotProtocolsEndpoint:
"""GET /api/hotspot/protocols lists the protocols a device can host."""
def test_protocols_endpoint_shape(self, test_client):
"""Should probe the requested device."""
from unittest import mock
with mock.patch("controller.api.admin.get_device_hotspot_protocols",
return_value=["wpa2", "wpa3"]) as mock_protocols:
response = test_client.get("/api/hotspot/protocols?device=wlp2s0")
assert response.status_code == 200
assert response.json() == {"device": "wlp2s0", "protocols": ["wpa2", "wpa3"]}
mock_protocols.assert_called_once_with("wlp2s0")
def test_protocols_defaults_to_configured_device(self, test_client):
"""Should probe the configured hotspot device when none is given."""
from unittest import mock
with mock.patch("controller.api.admin.get_device_hotspot_protocols", return_value=["wpa2"]):
response = test_client.get("/api/hotspot/protocols")
assert response.status_code == 200
assert response.json() == {"device": "wlan0", "protocols": ["wpa2"]}
def test_protocols_empty_in_docker(self, test_client_docker_mode):
"""Docker mode cannot host a hotspot."""
response = test_client_docker_mode.get("/api/hotspot/protocols?device=wlan0")
assert response.status_code == 200
assert response.json() == {"device": "wlan0", "protocols": []}
class TestHotspotSettingsEndpoint:
"""GET/POST /api/hotspot/settings manage hotspot settings in controller.yaml."""
@ -59,23 +87,31 @@ class TestHotspotSettingsEndpoint:
"""Should return the current hotspot settings."""
response = test_client.get("/api/hotspot/settings")
assert response.status_code == 200
assert response.json() == {"device": "wlan0", "ssid": "WROLPi", "password": "wrolpi hotspot"}
assert response.json() == {"device": "wlan0", "ssid": "WROLPi", "password": "wrolpi hotspot",
"protocol": "wpa2"}
def test_post_settings(self, test_client, mock_config_path):
"""Should update settings and persist them to controller.yaml."""
response = test_client.post(
"/api/hotspot/settings",
json={"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123"},
json={"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123", "protocol": "wpa3"},
)
assert response.status_code == 200
assert response.json() == {"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123"}
assert response.json() == {"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123",
"protocol": "wpa3"}
assert "wlp2s0" in mock_config_path.read_text()
assert "wpa3" in mock_config_path.read_text()
def test_post_rejects_short_password(self, test_client, mock_config_path):
"""Should reject a password shorter than 8 characters."""
response = test_client.post("/api/hotspot/settings", json={"password": "short"})
assert response.status_code == 400
def test_post_rejects_invalid_protocol(self, test_client, mock_config_path):
"""Should reject protocols other than wpa2/wpa3."""
response = test_client.post("/api/hotspot/settings", json={"protocol": "wep"})
assert response.status_code == 400
def test_post_rejected_in_docker(self, test_client_docker_mode):
"""Should reject settings changes in Docker mode."""
response = test_client_docker_mode.post("/api/hotspot/settings", json={"device": "wlp2s0"})