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
+113 -12
View File
@@ -3,10 +3,73 @@
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
import threading
import time
from web_interface.blueprints.api_v3 import (
api_v3, describe_exception, jsonify, logger, request,
)
# Joining a network while the setup AP is up has to take the AP down first,
# and the caller is almost always a phone connected *through* that AP. Run
# synchronously, the request outlives the link it arrived on: the browser
# never gets an answer and the Connect button appears to do nothing. So in AP
# mode the connect runs in the background and the result is kept here, where
# /wifi/status can report it once the user is back on either network.
_connect_lock = threading.Lock()
_last_connect_attempt = None
# Lets the 202 reach the phone before hostapd stops.
_AP_HANDOFF_DELAY_SECONDS = 2.0
def _last_connect_snapshot():
with _connect_lock:
return dict(_last_connect_attempt) if _last_connect_attempt else None
def _spawn(target):
threading.Thread(target=target, name='wifi-connect', daemon=True).start()
def _connect_result_payload(ssid, success, message):
"""Shape a connect_to_network result for the client.
The manager flags a rejected passphrase by prefixing the message with
"wrong_password:"; the setup pages key off `error_type` rather than
parsing nmcli's wording.
"""
if success:
return {'status': 'success', 'message': message}
payload = {'status': 'error', 'message': message or 'Failed to connect to network'}
if message and message.startswith('wrong_password:'):
payload['error_type'] = 'wrong_password'
payload['message'] = f'Incorrect password for {ssid}'
return payload
def _record_connect_result(ssid, payload):
global _last_connect_attempt
with _connect_lock:
_last_connect_attempt = {
'ssid': ssid,
'state': 'success' if payload['status'] == 'success' else 'failed',
'message': payload['message'],
'error_type': payload.get('error_type'),
'finished_at': time.time(),
}
def _run_background_connect(ssid, password):
try:
time.sleep(_AP_HANDOFF_DELAY_SECONDS)
from src.wifi_manager import WiFiManager
success, message = WiFiManager().connect_to_network(ssid, password)
payload = _connect_result_payload(ssid, success, message)
except Exception as e:
logger.error("Background WiFi connect failed", exc_info=True)
payload = {'status': 'error', 'message': describe_exception(e)}
_record_connect_result(ssid, payload)
def _parse_bool_ish(value):
"""Coerce a JSON value that is supposed to be a boolean.
@@ -65,7 +128,8 @@ def get_wifi_status():
'ip_address': status.ip_address,
'signal': status.signal,
'ap_mode_active': status.ap_mode_active,
'auto_enable_ap_mode': auto_enable_ap
'auto_enable_ap_mode': auto_enable_ap,
'last_connect_attempt': _last_connect_snapshot(),
}
})
except Exception as e:
@@ -149,7 +213,12 @@ def scan_wifi_networks():
}), 500
@api_v3.route('/wifi/connect', methods=['POST'])
def connect_wifi():
"""Connect to a WiFi network"""
"""Connect to a WiFi network.
With the setup AP active this answers 202 at once and connects in the
background (see _last_connect_attempt); otherwise it waits for the result.
"""
global _last_connect_attempt
try:
from src.wifi_manager import WiFiManager
@@ -177,18 +246,50 @@ def connect_wifi():
password = data.get('password', '') or ''
wifi_manager = WiFiManager()
success, message = wifi_manager.connect_to_network(ssid, password)
ap_mode_active = wifi_manager._is_ap_mode_active()
if success:
# One attempt at a time on either path: concurrent connects fight over
# the radio, and the first to finish would clear the in-progress flag
# the monitor daemon still needs for the other. The check can't depend
# on AP state either -- a background attempt takes the AP down long
# before it finishes.
with _connect_lock:
if _last_connect_attempt and _last_connect_attempt['state'] == 'pending':
return jsonify({
'status': 'error',
'message': f"Already connecting to {_last_connect_attempt['ssid']}"
}), 409
_last_connect_attempt = {
'ssid': ssid, 'state': 'pending', 'message': None,
'error_type': None, 'finished_at': None,
}
if ap_mode_active:
try:
_spawn(lambda: _run_background_connect(ssid, password))
except Exception:
# Nothing will ever finish this attempt; don't leave every
# later request refused.
with _connect_lock:
_last_connect_attempt = None
raise
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message or 'Failed to connect to network'
}), 400
'status': 'pending',
'message': (
f'Connecting to {ssid}. The LEDMatrix-Setup network will turn off, '
'so this page will lose its connection.'
),
'data': {'ssid': ssid},
}), 202
try:
success, message = wifi_manager.connect_to_network(ssid, password)
except Exception as e:
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
raise
payload = _connect_result_payload(ssid, success, message)
_record_connect_result(ssid, payload)
return jsonify(payload), (200 if success else 400)
except Exception as e:
logger.error("Error connecting to WiFi", exc_info=True)
return jsonify({
+81 -14
View File
@@ -99,6 +99,16 @@ select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline
</div>
</div>
<div id="handoff-view" class="card hidden" tabindex="-1" aria-labelledby="handoff-title">
<div class="success-box">
<svg class="icon" style="color:var(--accent)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
<p class="title" id="handoff-title">Connecting to <span id="handoff-ssid"></span></p>
<p class="hint">LEDMatrix-Setup has turned off, so this page can’t follow along any more.</p>
<p class="hint"><strong>If it worked:</strong> the LED display shows the new address. Rejoin your home WiFi and open it.</p>
<p class="hint"><strong>If it didn’t:</strong> LEDMatrix-Setup comes back within a few minutes. Rejoin it and reopen this page to see why.</p>
</div>
</div>
<div class="footer">
<a href="/">Open Full Interface</a>
</div>
@@ -259,17 +269,16 @@ function doConnect() {
}, 60000)
.then(readJson)
.then(function(data) {
if (data.status === 'success') {
if (data.status === 'pending') {
// Connecting in the background; this network is about to go away.
showMsg(handoffMessage(ssid), 'info');
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else if (data.status === 'success') {
clearMsg();
// Poll for the new IP
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
var msg = data.error_type === 'wrong_password'
? 'Incorrect password — please try again.'
: (data.message || 'Couldn’t connect to ' + ssid + '. Check the network name and password, then try again.');
showMsg(msg, 'err');
resetConnect();
if (data.error_type === 'wrong_password') $('password').focus();
showConnectError(data, ssid);
}
})
.catch(function(e) {
@@ -279,12 +288,49 @@ function doConnect() {
return;
}
// Connection may drop if AP mode was disabled — that's expected
clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
setTimeout(function() { showSuccessFallback(ssid); }, 5000);
showHandoff(ssid);
});
}
function handoffMessage(ssid) {
return 'Connecting to ' + ssid + '. LEDMatrix-Setup will turn off and your phone will disconnect from it. ' +
'If it works, the LED display shows its new address. If not, LEDMatrix-Setup comes back within a few minutes: ' +
'rejoin it and reopen this page to see why.';
}
function resetConnectBtn() {
var btn = $('btn-connect');
connecting = false;
btn.removeAttribute('aria-busy');
btn.textContent = 'Connect';
updateConnectBtn();
}
// `attempt` is a /wifi/connect error body or a failed last_connect_attempt.
function showConnectError(attempt, ssid) {
var name = attempt.ssid || ssid;
var wrongPassword = attempt.error_type === 'wrong_password';
var msg = wrongPassword
? 'Incorrect password for ' + name + ' — please try again.'
: (attempt.message || 'Couldn’t connect to ' + name + '. Check the network name and password, then try again.');
// A background failure brings the AP back: put the form back too.
$('handoff-view').classList.add('hidden');
$('setup-form').classList.remove('hidden');
showMsg(msg, 'err');
resetConnectBtn();
if (wrongPassword) $('password').focus();
}
function showHandoff(ssid) {
$('setup-form').classList.add('hidden');
$('success-view').classList.add('hidden');
var view = $('handoff-view');
view.classList.remove('hidden');
$('handoff-ssid').textContent = ssid;
clearMsg();
view.focus();
}
var MAX_IP_RETRIES = 20;
function checkNewIP(ssid, retriesLeft) {
@@ -296,18 +342,38 @@ function checkNewIP(ssid, retriesLeft) {
fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
.then(readJson)
.then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address);
var d = (data.status === 'success' && data.data) || {};
var attempt = d.last_connect_attempt;
if (attempt && attempt.ssid === ssid && attempt.state === 'failed') {
showConnectError(attempt);
} else if (d.connected && d.ip_address && !d.ap_mode_active) {
showSuccess(d.ip_address);
} else {
setTimeout(function() { checkNewIP(ssid, retriesLeft - 1); }, 3000);
}
})
.catch(function() {
// AP likely down — show fallback
showSuccessFallback(ssid);
// The setup network went down, which is what connecting does.
showHandoff(ssid);
});
}
// A failed attempt brings LEDMatrix-Setup back; tell the user who rejoined why.
function showPreviousFailure() {
fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
.then(readJson)
.then(function(data) {
var attempt = data.data && data.data.last_connect_attempt;
if (attempt && attempt.state === 'failed' && !data.data.connected) {
var reason = attempt.error_type === 'wrong_password'
? 'the password was incorrect'
: (attempt.message || 'an unknown error');
showMsg('Last attempt to connect to ' + attempt.ssid + ' failed: ' + reason + '.', 'err');
}
})
.catch(function() {});
}
function revealSuccess(text) {
$('setup-form').classList.add('hidden');
var view = $('success-view');
@@ -326,6 +392,7 @@ function showSuccessFallback(ssid) {
}
// Auto-scan on load
showPreviousFailure();
doScan();
</script>
</body>
+73 -4
View File
@@ -197,7 +197,11 @@
<!-- Messages -->
<div x-show="message"
x-text="message"
:class="messageType === 'error' ? 'bg-red-50 border-red-200 text-red-800' : 'bg-green-50 border-green-200 text-green-800'"
:class="{
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800'
}[messageType] || 'bg-green-50 border-green-200 text-green-800'"
class="mt-4 p-3 rounded border"
x-cloak></div>
</div>
@@ -214,6 +218,7 @@ function wifiSetup() {
ap_mode_active: false,
auto_enable_ap_mode: true // Default: true (safe due to grace period)
},
statusLoaded: false,
networks: [],
selectedSSID: '',
manualSSID: '',
@@ -258,7 +263,15 @@ function wifiSetup() {
const response = await fetch('/api/v3/wifi/status');
const data = await response.json();
if (data.status === 'success') {
const firstLoad = !this.statusLoaded;
this.status = data.data;
this.statusLoaded = true;
// A failed attempt brings LEDMatrix-Setup back; tell the
// user who rejoined it why.
const attempt = data.data.last_connect_attempt;
if (firstLoad && attempt && attempt.state === 'failed' && !data.data.connected) {
this.showMessage(this.describeFailedAttempt(attempt), 'error');
}
} else {
this.showMessage(data.message, 'error');
}
@@ -316,6 +329,10 @@ function wifiSetup() {
this.connecting = true;
this.message = '';
const viaSetupNetwork = this.status.ap_mode_active;
if (viaSetupNetwork) {
this.showMessage(this.handoffMessage(ssid.trim()), 'info');
}
try {
const response = await fetch('/api/v3/wifi/connect', {
method: 'POST',
@@ -327,7 +344,16 @@ function wifiSetup() {
password: this.password || ''
})
});
if (response.status === 202) {
// Connecting in the background; the setup network is about
// to go down and take this page's connection with it.
this.password = '';
this.showMessage(this.handoffMessage(ssid.trim()), 'info');
this.pollConnectResult(ssid.trim());
return;
}
// Check if response is ok before parsing JSON
if (!response.ok) {
// Try to parse error message from response
@@ -362,13 +388,56 @@ function wifiSetup() {
this.showMessage(data.message || 'Failed to connect to network', 'error');
}
} catch (error) {
console.error('WiFi connect error:', error);
this.showMessage('Failed to connect: ' + error.message, 'error');
if (viaSetupNetwork) {
// The setup network went down mid-request: that is the
// connection attempt happening, not a failure.
this.showMessage(this.handoffMessage(ssid.trim()), 'info');
} else {
console.error('WiFi connect error:', error);
this.showMessage('Failed to connect: ' + error.message, 'error');
}
} finally {
this.connecting = false;
}
},
handoffMessage(ssid) {
return `Connecting to ${ssid}. LEDMatrix-Setup will turn off, so this page will stop responding. ` +
`If it works, the LED display shows the new address; rejoin your home WiFi and open it. ` +
`If it fails, LEDMatrix-Setup comes back within a few minutes: rejoin it and reopen this page to see why.`;
},
// Follow a background connect until its result lands or the setup
// network drops out from under this page.
async pollConnectResult(ssid, retriesLeft = 20) {
if (retriesLeft <= 0) return;
await new Promise(resolve => setTimeout(resolve, 3000));
try {
const response = await fetch('/api/v3/wifi/status');
const data = await response.json();
const attempt = data.data && data.data.last_connect_attempt;
if (attempt && attempt.ssid === ssid && attempt.state !== 'pending') {
this.status = data.data;
if (attempt.state === 'success') {
this.showMessage(attempt.message || `Connected to ${ssid}`, 'success');
} else {
this.showMessage(this.describeFailedAttempt(attempt), 'error');
}
return;
}
} catch (error) {
return; // Setup network is down; the handoff message already explains.
}
this.pollConnectResult(ssid, retriesLeft - 1);
},
describeFailedAttempt(attempt) {
const reason = attempt.error_type === 'wrong_password'
? 'the password was incorrect'
: (attempt.message || 'unknown error');
return `Could not connect to ${attempt.ssid}: ${reason}.`;
},
async disconnectFromNetwork() {
if (!this.status.connected) {
this.showMessage('Not connected to any network', 'warning');