Files
LEDMatrix/web_interface/templates/v3/captive_setup.html
ChuckandClaude Opus 5 7e580dc005 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>
2026-09-14 12:40:41 -04:00

400 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>LEDMatrix WiFi Setup</title>
<style>
:root{--bg:#f3f4f6;--surface:#fff;--text:#1f2937;--label:#374151;--muted:#4b5563;--faint:#6b7280;--line:#e5e7eb;--field:#d1d5db;--accent:#2563eb;--accent-hover:#1d4ed8;--scan:#e5e7eb;--scan-hover:#d1d5db;--ok-bg:#d1fae5;--ok:#065f46;--err-bg:#fee2e2;--err:#991b1b;--info-bg:#dbeafe;--info:#1e40af}
@media (prefers-color-scheme:dark){:root{--bg:#111827;--surface:#1f2937;--text:#f9fafb;--label:#d1d5db;--muted:#d1d5db;--faint:#9ca3af;--line:#374151;--field:#4b5563;--accent:#3b82f6;--accent-hover:#60a5fa;--scan:#374151;--scan-hover:#4b5563;--ok-bg:#064e3b;--ok:#6ee7b7;--err-bg:#7f1d1d;--err:#fecaca;--info-bg:#1e3a5f;--info:#bfdbfe}}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);padding:16px;max-width:480px;margin:0 auto;line-height:1.45}
h1{font-size:20px;margin-bottom:4px}
.subtitle{color:var(--muted);font-size:14px;margin-bottom:20px}
.card{background:var(--surface);border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:14px;font-weight:600;color:var(--label);margin-bottom:6px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* 16px inputs: iOS Safari zooms any focused field smaller than that */
select,input[type=text],input[type=password]{width:100%;min-width:0;min-height:44px;padding:10px 12px;border:1px solid var(--field);border-radius:8px;font-size:16px;background-color:var(--surface);color:var(--text);-webkit-appearance:none;appearance:none}
select{padding-right:32px;text-overflow:ellipsis;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--accent)}
.btn{display:block;width:100%;min-height:44px;padding:12px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;text-align:center;transition:background-color .15s}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-scan{background:var(--scan);color:var(--label);width:auto;padding:10px 16px;white-space:nowrap}.btn-scan:hover{background:var(--scan-hover)}
.btn:disabled{background:var(--scan);color:var(--muted);cursor:not-allowed;opacity:.75}
.row{display:flex;gap:8px;margin-bottom:16px}
.row>*:first-child{flex:1;min-width:0}
.msg{padding:12px;border-radius:8px;font-size:14px;margin-bottom:12px;display:none;overflow-wrap:anywhere}
.msg-ok{background:var(--ok-bg);color:var(--ok);display:block}
.msg-err{background:var(--err-bg);color:var(--err);display:block}
.msg-info{background:var(--info-bg);color:var(--info);display:block}
.step{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .6s linear infinite;vertical-align:-2px;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.spinner{animation-duration:1.8s}.btn{transition:none}}
.footer{text-align:center;margin-top:20px;font-size:14px}
.footer a{color:var(--accent);text-underline-offset:.15em;display:inline-block;padding:10px}
.success-box{text-align:center;padding:24px}
.success-box .icon{width:48px;height:48px;margin:0 auto 12px;color:#059669}
.success-box .title{font-size:16px;font-weight:600;margin-bottom:8px}
.success-box .hint{font-size:14px;color:var(--muted);margin-bottom:12px}
.success-box .note{font-size:13px;color:var(--faint);margin-top:12px}
.success-box .ip{font-size:18px;font-weight:700;color:var(--accent);word-break:break-all}
.hidden{display:none}
.or-divider{text-align:center;color:var(--faint);font-size:13px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:30%;height:1px;background:var(--line)}
.or-divider::before{left:0}
.or-divider::after{right:0}
</style>
</head>
<body>
<h1>LEDMatrix WiFi Setup</h1>
<p class="subtitle">Connect your device to a WiFi network</p>
<div id="msg" class="msg" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="setup-form">
<div class="card">
<div class="step" aria-hidden="true">Step 1 &mdash; Choose Network</div>
<label for="net-select">Available Networks</label>
<div class="row">
<select id="net-select" onchange="onSelectNetwork()">
<option value="">-- Scan to find networks --</option>
</select>
<button type="button" class="btn btn-scan" id="btn-scan" onclick="doScan()">
Scan
</button>
</div>
<div class="or-divider" aria-hidden="true">or enter manually</div>
<label for="manual-ssid" class="sr-only">Network name (SSID), if it isn't listed</label>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" maxlength="32"
autocomplete="off" autocapitalize="off" spellcheck="false" oninput="onManualInput()">
</div>
<div class="card">
<div class="step" aria-hidden="true">Step 2 &mdash; Password</div>
<label for="password">WiFi Password</label>
<input type="password" id="password" placeholder="Leave empty for open networks" maxlength="63"
autocomplete="current-password" aria-describedby="password-hint">
<p id="password-hint" class="sr-only">Leave empty for open networks.</p>
</div>
<div class="card">
<button type="button" class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
Connect
</button>
</div>
</div>
<div id="success-view" class="card hidden" tabindex="-1" aria-labelledby="success-title">
<div class="success-box">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M7.5 12.5l3 3 6-6"/></svg>
<p class="title" id="success-title">Connected!</p>
<p class="hint">Your device is now on the network. Access the full interface at:</p>
<p class="ip" id="new-ip"></p>
<p class="note">You may need to reconnect your phone to the same WiFi network.</p>
</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>
<script>
var selectedSSID = '';
var scanning = false;
var connecting = false;
var msgTimer = null;
function $(id) { return document.getElementById(id); }
// fetch() with a deadline: on a flaky AP link a request can otherwise hang
// forever and leave the buttons stuck in their busy state.
function fetchWithTimeout(url, options, ms) {
options = options || {};
if (typeof AbortController === 'undefined') return fetch(url, options);
var controller = new AbortController();
var timer = setTimeout(function() { controller.abort(); }, ms);
options.signal = controller.signal;
return fetch(url, options).finally(function() { clearTimeout(timer); });
}
function readJson(r) {
return r.json().catch(function() {
throw new Error(r.ok ? 'bad-response' : 'http-' + r.status);
});
}
function setBusy(btn, label) {
btn.textContent = '';
var spin = document.createElement('span');
spin.className = 'spinner';
spin.setAttribute('aria-hidden', 'true');
btn.appendChild(spin);
btn.appendChild(document.createTextNode(label));
}
function showMsg(text, type) {
var el = $('msg');
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
el.style.display = '';
// Errors interrupt; progress and success wait their turn.
el.setAttribute('role', type === 'err' ? 'alert' : 'status');
el.setAttribute('aria-live', type === 'err' ? 'assertive' : 'polite');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') msgTimer = setTimeout(function() { el.style.display = 'none'; }, 8000);
}
function clearMsg() {
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
$('msg').className = 'msg';
$('msg').textContent = '';
}
function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
$('btn-connect').disabled = !ssid || connecting;
}
function onSelectNetwork() {
$('manual-ssid').value = '';
selectedSSID = $('net-select').value;
updateConnectBtn();
}
function onManualInput() {
$('net-select').value = '';
selectedSSID = '';
updateConnectBtn();
}
function doScan() {
if (scanning) return;
scanning = true;
var btn = $('btn-scan');
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Scanning');
clearMsg();
fetchWithTimeout('/api/v3/wifi/scan', {}, 30000)
.then(readJson)
.then(function(data) {
var sel = $('net-select');
var previous = sel.value;
sel.innerHTML = '<option value="">-- Select a network --</option>';
if (data.status === 'success' && Array.isArray(data.data)) {
var seen = {};
var nets = data.data.filter(function(n) {
if (!n || !n.ssid || seen[n.ssid]) return false;
seen[n.ssid] = true;
return true;
});
for (var i = 0; i < nets.length; i++) {
var n = nets[i];
var opt = document.createElement('option');
opt.value = n.ssid;
var details = [];
if (n.signal !== undefined && n.signal !== null) details.push(n.signal + '%');
if (n.security) details.push(n.security);
opt.textContent = n.ssid + (details.length ? ' (' + details.join(' - ') + ')' : '');
sel.appendChild(opt);
}
if (previous && seen[previous]) sel.value = previous;
if (nets.length > 0) {
var msg = 'Found ' + nets.length + (nets.length === 1 ? ' network' : ' networks');
if (data.cached) {
msg += ' — showing cached networks. Connect to see the latest.';
}
showMsg(msg, data.cached ? 'info' : 'ok');
} else {
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again, or enter your network name manually.'), 'info');
}
} else {
showMsg(data.message || 'Scan failed. Try again, or enter your network name manually.', 'err');
}
})
.catch(function(e) {
var why = e && e.name === 'AbortError'
? 'The scan took too long.'
: 'Couldn’t reach the device to scan.';
showMsg(why + ' Stay close to the device and try again, or enter your network name manually.', 'err');
})
.finally(function() {
scanning = false;
btn.disabled = false;
btn.removeAttribute('aria-busy');
btn.textContent = 'Scan';
updateConnectBtn();
});
}
function doConnect() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
if (!ssid || connecting) return;
connecting = true;
var btn = $('btn-connect');
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Connecting…');
clearMsg();
showMsg('Connecting to ' + ssid + '… This may take 15–30 seconds.', 'info');
function resetConnect() {
connecting = false;
btn.disabled = false;
btn.removeAttribute('aria-busy');
btn.textContent = 'Connect';
updateConnectBtn();
}
fetchWithTimeout('/api/v3/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
}, 60000)
.then(readJson)
.then(function(data) {
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 {
showConnectError(data, ssid);
}
})
.catch(function(e) {
if (e && /^http-|^bad-response$/.test(e.message)) {
showMsg('The device couldn’t start the connection. Try again in a moment.', 'err');
resetConnect();
return;
}
// Connection may drop if AP mode was disabled — that's expected
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) {
if (retriesLeft === undefined) retriesLeft = MAX_IP_RETRIES;
if (retriesLeft <= 0) {
showSuccessFallback(ssid);
return;
}
fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
.then(readJson)
.then(function(data) {
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() {
// 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');
view.classList.remove('hidden');
$('new-ip').textContent = text;
clearMsg();
view.focus();
}
function showSuccess(ip) {
revealSuccess('http://' + ip + ':5000');
}
function showSuccessFallback(ssid) {
revealSuccess('Check your router for the device IP');
}
// Auto-scan on load
showPreviousFailure();
doScan();
</script>
</body>
</html>