mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
+39
-1
@@ -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
|
||||
|
||||
@@ -26,6 +26,9 @@ def wifi_manager():
|
||||
"""Patch WiFiManager where it is defined; yield the instance mock."""
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
instance = MagicMock()
|
||||
# A bare MagicMock is truthy, which would send every connect down
|
||||
# the setup-AP background path.
|
||||
instance._is_ap_mode_active.return_value = False
|
||||
cls.return_value = instance
|
||||
yield instance
|
||||
|
||||
@@ -97,6 +100,129 @@ class TestConnect:
|
||||
assert "RuntimeError" in body["details"]
|
||||
|
||||
|
||||
class TestConnectThroughSetupAp:
|
||||
"""With the setup AP up, the phone making the request is connected through
|
||||
the very network the connect tears down. A synchronous answer can never
|
||||
arrive, so the route answers 202 first and connects in the background."""
|
||||
|
||||
URL = "/api/v3/wifi/connect"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ap_active(self, api_v3_module, wifi_manager, monkeypatch):
|
||||
from web_interface.blueprints.api_v3 import wifi as wifi_routes
|
||||
wifi_manager._is_ap_mode_active.return_value = True
|
||||
monkeypatch.setattr(wifi_routes, "_AP_HANDOFF_DELAY_SECONDS", 0)
|
||||
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
|
||||
self.spawned = []
|
||||
monkeypatch.setattr(wifi_routes, "_spawn", self.spawned.append)
|
||||
self.routes = wifi_routes
|
||||
|
||||
def _run_spawned(self):
|
||||
assert len(self.spawned) == 1
|
||||
self.spawned.pop()()
|
||||
|
||||
def test_answers_202_before_connecting(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
|
||||
assert response.status_code == 202
|
||||
assert response.get_json()["status"] == "pending"
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
assert self.routes._last_connect_snapshot()["state"] == "pending"
|
||||
|
||||
def test_background_result_is_reported_by_status(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
|
||||
self._run_spawned()
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")
|
||||
|
||||
wifi_manager.config = {}
|
||||
wifi_manager.get_wifi_status.return_value = MagicMock(
|
||||
connected=True, ssid="HomeNet", ip_address="10.0.0.5", signal=70,
|
||||
ap_mode_active=False)
|
||||
attempt = api_v3_client.get("/api/v3/wifi/status").get_json()["data"]["last_connect_attempt"]
|
||||
assert attempt["ssid"] == "HomeNet"
|
||||
assert attempt["state"] == "success"
|
||||
assert "hunter22" not in str(attempt)
|
||||
|
||||
def test_wrong_password_is_flagged(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (
|
||||
False, "wrong_password: Secrets were required, but not provided")
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
|
||||
self._run_spawned()
|
||||
attempt = self.routes._last_connect_snapshot()
|
||||
assert attempt["state"] == "failed"
|
||||
assert attempt["error_type"] == "wrong_password"
|
||||
assert attempt["message"] == "Incorrect password for HomeNet"
|
||||
|
||||
def test_manager_exception_is_recorded_as_a_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli vanished")
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
self._run_spawned()
|
||||
attempt = self.routes._last_connect_snapshot()
|
||||
assert attempt["state"] == "failed"
|
||||
assert "RuntimeError" in attempt["message"]
|
||||
|
||||
def test_a_second_connect_while_one_is_pending_is_refused(self, api_v3_client, wifi_manager):
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
|
||||
assert response.status_code == 409
|
||||
assert len(self.spawned) == 1
|
||||
|
||||
def test_pending_is_honoured_after_the_ap_has_gone_down(self, api_v3_client, wifi_manager):
|
||||
# The background attempt tears the AP down long before it finishes; a
|
||||
# second click must not slip through as a synchronous connect.
|
||||
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
wifi_manager._is_ap_mode_active.return_value = False
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
|
||||
assert response.status_code == 409
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
def test_a_thread_that_fails_to_start_does_not_leave_pending_behind(
|
||||
self, api_v3_client, wifi_manager, monkeypatch):
|
||||
def refuse(_target):
|
||||
raise RuntimeError("can't start new thread")
|
||||
monkeypatch.setattr(self.routes, "_spawn", refuse)
|
||||
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
|
||||
assert self.routes._last_connect_snapshot() is None
|
||||
|
||||
monkeypatch.setattr(self.routes, "_spawn", self.spawned.append)
|
||||
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 202
|
||||
|
||||
|
||||
class TestConnectSerializedWithoutAp:
|
||||
"""Without the AP the route connects synchronously, but still one attempt
|
||||
at a time: a second connect_to_network would remove the in-progress flag
|
||||
the daemon relies on when the first finishes."""
|
||||
|
||||
URL = "/api/v3/wifi/connect"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_prior_attempt(self, api_v3_module, monkeypatch):
|
||||
from web_interface.blueprints.api_v3 import wifi as wifi_routes
|
||||
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
|
||||
self.routes = wifi_routes
|
||||
|
||||
def test_a_connect_during_a_synchronous_connect_is_refused(
|
||||
self, api_v3_client, wifi_manager):
|
||||
inner = []
|
||||
|
||||
def connect(ssid, password):
|
||||
if not inner:
|
||||
inner.append(api_v3_client.post(self.URL, json={"ssid": "OtherNet"}))
|
||||
return True, f"Connected to {ssid}"
|
||||
|
||||
wifi_manager.connect_to_network.side_effect = connect
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 200
|
||||
assert inner[0].status_code == 409
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
|
||||
assert self.routes._last_connect_snapshot()["state"] == "success"
|
||||
|
||||
def test_an_exception_does_not_leave_pending_behind(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli gone")
|
||||
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
|
||||
assert self.routes._last_connect_snapshot()["state"] == "failed"
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
URL = "/api/v3/wifi/disconnect"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -134,6 +134,7 @@ class TestConnectEndpointSurfacesTheRefusal:
|
||||
def wifi_manager(self):
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
instance = MagicMock()
|
||||
instance._is_ap_mode_active.return_value = False
|
||||
cls.return_value = instance
|
||||
yield instance
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user