From de25242d619024711f73d0544dfcea2d3c5f0579 Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 28 Mar 2026 10:48:34 -0600 Subject: [PATCH] Waiting for wifi device to become ready before creating hotspot. --- controller/lib/admin.py | 26 ++++++++++++++ controller/test/test_admin.py | 64 ++++++++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/controller/lib/admin.py b/controller/lib/admin.py index b04616a0..6466b056 100644 --- a/controller/lib/admin.py +++ b/controller/lib/admin.py @@ -175,6 +175,32 @@ def enable_hotspot() -> dict: timeout=10, ) + # Wait for device to become ready after radio on. + # On real hardware, the device transitions from 'unavailable' to 'disconnected' + # which can take a few seconds. + import time + device_ready = False + for _ in range(20): # 20 * 0.5s = 10s max + check = subprocess.run( + ["nmcli", "-t", "-f", "DEVICE,STATE", "device", "status"], + capture_output=True, + text=True, + timeout=5, + ) + if check.returncode == 0: + for line in check.stdout.strip().splitlines(): + parts = line.split(":") + if len(parts) >= 2 and parts[0] == device and parts[1] != "unavailable": + device_ready = True + break + if device_ready: + break + time.sleep(0.5) + + if not device_ready: + logger.warning("WiFi device %s did not become ready after turning radio on", device) + return {"success": False, "error": f"WiFi device {device} not ready after turning radio on"} + # Create hotspot using NetworkManager logger.info("Enabling hotspot on %s with SSID %s", device, ssid) result = subprocess.run( diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py index f4b3c1d0..ddce8b07 100644 --- a/controller/test/test_admin.py +++ b/controller/test/test_admin.py @@ -357,7 +357,6 @@ class TestEnableHotspot: def test_uses_config_values(self): """Should use get_config_value for device, ssid, and password.""" with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): - # Mock get_config_value to return custom values def mock_get_config(key, default=None): config_map = { 'hotspot.device': 'wlan1', @@ -367,20 +366,59 @@ class TestEnableHotspot: return config_map.get(key, default) with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config): - mock_run = mock.Mock() - mock_run.returncode = 0 - with mock.patch("subprocess.run", return_value=mock_run) as mock_subprocess: - enable_hotspot() + 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="") + + with mock.patch("subprocess.run", side_effect=[radio_on, device_ready, hotspot_ok]) as mock_subprocess: + with mock.patch("time.sleep"): + enable_hotspot() - # Verify the hotspot was created with config values - calls = mock_subprocess.call_args_list # Find the hotspot creation call (nmcli device wifi hotspot) - hotspot_call = [c for c in calls if len(c[0][0]) > 2 and 'hotspot' in c[0][0]] - if hotspot_call: - cmd_args = hotspot_call[0][0][0] - assert "wlan1" in cmd_args - assert "TestSSID" in cmd_args - assert "testpassword123" in cmd_args + calls = mock_subprocess.call_args_list + hotspot_call = [c for c in calls if 'hotspot' in c[0][0]] + assert hotspot_call, "Hotspot creation call not found" + cmd_args = hotspot_call[0][0][0] + assert "wlan1" in cmd_args + assert "TestSSID" in cmd_args + assert "testpassword123" in cmd_args + + + def test_waits_for_device_ready_after_radio_on(self): + """Should poll device status after turning radio on, waiting for device + to transition from 'unavailable' before creating hotspot. + Real RPi behavior: radio on takes ~2s for device to become 'disconnected'.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + # Simulate: radio on succeeds, device initially unavailable, then disconnected, then hotspot succeeds + radio_on_result = mock.Mock(returncode=0, stdout="", stderr="") + device_unavailable = mock.Mock(returncode=0, stdout="wlan0:unavailable\n", stderr="") + device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="") + hotspot_result = mock.Mock(returncode=0, stdout="", stderr="") + + with mock.patch("subprocess.run", side_effect=[ + radio_on_result, # nmcli radio wifi on + device_unavailable, # nmcli -t device status (not ready) + device_ready, # nmcli -t device status (ready) + hotspot_result, # nmcli device wifi hotspot + ]): + with mock.patch("time.sleep"): # Don't actually sleep in tests + result = enable_hotspot() + assert result["success"] is True + + def test_fails_if_device_never_becomes_ready(self): + """Should fail with a clear error if device stays unavailable after radio on.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + radio_on_result = mock.Mock(returncode=0, stdout="", stderr="") + device_unavailable = mock.Mock(returncode=0, stdout="wlan0:unavailable\n", stderr="") + + # Device never becomes ready (all polls return unavailable) + with mock.patch("subprocess.run", side_effect=[ + radio_on_result, + ] + [device_unavailable] * 20): + with mock.patch("time.sleep"): + result = enable_hotspot() + assert result["success"] is False + assert "not ready" in result.get("error", "").lower() or "unavailable" in result.get("error", "").lower() class TestDisableHotspot: