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
+39 -1
View File
@@ -714,9 +714,24 @@ class WiFiManager:
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Written by the web process while connect_to_network runs. Joining a network
# from the setup AP takes the AP down first, and the monitor daemon (a separate
# process) would otherwise see "disconnected" on its next tick and bring the
# AP straight back up mid-connect.
_CONNECT_IN_PROGRESS_FLAG_PATH = Path("/tmp/ledmatrix_wifi_connect_in_progress") # nosec B108 - process-specific named file; device is single-user RPi
# Longest a connect can legitimately take (AP teardown, nmcli's 30s timeout,
# verification, restore). An older flag was left by a process that died.
_CONNECT_FLAG_MAX_AGE_SECONDS = 180
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _connect_in_progress(self) -> bool:
try:
age = time.time() - self._CONNECT_IN_PROGRESS_FLAG_PATH.stat().st_mtime
except OSError:
return False
return age < self._CONNECT_FLAG_MAX_AGE_SECONDS
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
@@ -1270,6 +1285,21 @@ class WiFiManager:
logger.warning("Rejected WiFi connect request: %s", error)
return False, error
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.touch()
except OSError as e:
logger.warning(f"Could not create connect-in-progress flag: {e}")
try:
return self._connect_validated(ssid, password)
finally:
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.unlink(missing_ok=True)
except OSError as e:
# Never mask the connect result; the age limit retires the flag.
logger.warning(f"Could not remove connect-in-progress flag: {e}")
def _connect_validated(self, ssid: str, password: str) -> Tuple[bool, str]:
"""connect_to_network after validation, with the in-progress flag held."""
# Save current connection info for failsafe restoration
original_connection = None
original_ssid = None
@@ -2690,7 +2720,15 @@ address=/detectportal.firefox.com/192.168.4.1
if self._disconnected_checks > 0:
logger.debug("Network connected, resetting disconnected check counter")
self._disconnected_checks = 0
if self._connect_in_progress():
# A connect has just taken the AP down on purpose. Leave the
# radio alone, and restart the grace period so a failed attempt
# (which re-enables the AP itself) isn't followed by a flap.
logger.debug("WiFi connect in progress; skipping AP management this check")
self._disconnected_checks = 0
return False
# Only enable AP if we've had enough consecutive disconnected checks
should_have_ap = (auto_enable and
is_disconnected and