fix(wifi): make Connect work from the setup AP (#571)

* fix(wifi): make Connect work from the setup AP

Joining a network from LEDMatrix-Setup has to take the AP down first, which
drops the phone that sent the request. The connect endpoint answered only
after the attempt finished, so the browser never got a reply and the WiFi
tab's Connect button appeared to do nothing.

- /wifi/connect answers 202 immediately while the AP is active and connects
  in a background thread; the result (never the password) is reported via
  /wifi/status as last_connect_attempt. A second connect while one is
  pending gets 409.
- connect_to_network holds a /tmp flag for the attempt; the monitor daemon
  skips AP management while it is fresh. Previously the daemon's
  disconnected counter, accumulated over the whole AP session, re-enabled
  the AP on its next tick in the middle of the connect.
- The WiFi tab and captive setup page explain the handoff up front, and on
  reopening show why the last attempt failed. The wrong-password message
  now works: the route sets the error_type the captive page checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(wifi): serialize connect attempts on both paths

Addresses CodeRabbit review on #571:

- Check for a pending attempt before branching on AP state. A background
  attempt takes the AP down long before it finishes, so a second click
  used to bypass the 409 and start a competing synchronous connect.
- Record pending for the synchronous (non-AP) path too, so two requests
  can't overlap and have the first clear the daemon's in-progress flag
  while the second is still connecting.
- Clear the pending state if the background thread fails to start, rather
  than refusing every later request until restart.
- Say the setup network returns "within a few minutes": a stale flag plus
  the daemon's grace period can take longer than one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 12:40:41 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent d51f7ada14
commit 7e580dc005
7 changed files with 484 additions and 31 deletions
+51
View File
@@ -40,6 +40,57 @@ def _wire(wm, connected, ethernet, ap_active):
wm._save_cached_scan = MagicMock()
wm._FORCE_AP_FLAG_PATH = MagicMock()
wm._FORCE_AP_FLAG_PATH.exists.return_value = False
wm._connect_in_progress = MagicMock(return_value=False)
class TestConnectInProgressHoldsOffTheDaemon:
"""Connecting from the setup AP takes the AP down before joining. The
daemon had already counted the whole AP session as "disconnected", so its
next tick re-enabled the AP in the middle of the connect."""
def test_no_ap_enable_while_a_connect_is_running(self, wm):
_wire(wm, connected=False, ethernet=False, ap_active=False)
wm._connect_in_progress.return_value = True
wm._disconnected_checks = 10 # accumulated while the AP was up
changed, *_ = wm.check_and_manage_ap_mode_with_state()
assert changed is False
wm.enable_ap_mode.assert_not_called()
assert wm._disconnected_checks == 0
def test_grace_period_restarts_after_the_connect(self, wm):
_wire(wm, connected=False, ethernet=False, ap_active=False)
wm._connect_in_progress.return_value = True
wm._disconnected_checks = 10
wm.check_and_manage_ap_mode_with_state()
wm._connect_in_progress.return_value = False
wm.check_and_manage_ap_mode_with_state()
wm.enable_ap_mode.assert_not_called()
def test_flag_is_held_for_the_connect_and_removed_after(self, wm, tmp_path):
flag = tmp_path / "connecting"
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
seen = []
wm._connect_validated = lambda ssid, pw: seen.append(flag.exists()) or (True, "ok")
assert wm.connect_to_network("HomeNet", "hunter22") == (True, "ok")
assert seen == [True]
assert not flag.exists()
def test_flag_is_removed_when_the_connect_raises(self, wm, tmp_path):
flag = tmp_path / "connecting"
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
wm._connect_validated = MagicMock(side_effect=RuntimeError("nmcli gone"))
with pytest.raises(RuntimeError):
wm.connect_to_network("HomeNet", "hunter22")
assert not flag.exists()
def test_a_stale_flag_from_a_dead_process_is_ignored(self, wm, tmp_path):
flag = tmp_path / "connecting"
flag.touch()
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
assert WiFiManager._connect_in_progress(wm) is True
old = flag.stat().st_mtime - WiFiManager._CONNECT_FLAG_MAX_AGE_SECONDS - 1
os.utime(flag, (old, old))
assert WiFiManager._connect_in_progress(wm) is False
class TestWithState: