Add 24h migration window for legacy/unprovisioned nodes to connect while flagged as Unpaired rather than silently rejected. Surface unpaired nodes in the fleet UI with amber badge, pulsing status indicator, and migration window countdown banner. Add re-provision wizard entry point from fleet panel — clicking "Pair" on an unpaired node opens the onboarding wizard in re-provisioning mode, skipping firmware flash and going straight to serial credential provisioning. After migration window closes, nodes without valid tokens are rejected outright. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1713 lines
76 KiB
JavaScript
1713 lines
76 KiB
JavaScript
/**
|
|
* Spaxel Onboarding Wizard
|
|
*
|
|
* Interactive Web Serial-based setup wizard for provisioning ESP32-S3 nodes.
|
|
* States: BROWSER_CHECK → CONNECT_DEVICE → FLASH_FIRMWARE → PROVISION_WIFI
|
|
* → DETECT_NODE → CALIBRATE → PLACEMENT → COMPLETE
|
|
*/
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ============================================
|
|
// Configuration
|
|
// ============================================
|
|
var CONFIG = {
|
|
nodePollInterval: 3000,
|
|
nodePollTimeout: 120000,
|
|
calibrateWalkDuration: 30000,
|
|
calibrateStillDuration: 10000,
|
|
calibrateWalkThroughDuration: 15000,
|
|
storageKey: 'spaxel_onboard',
|
|
serialBaudRate: 115200,
|
|
provisioningEndpoint: '/api/provision',
|
|
nodesEndpoint: '/api/nodes',
|
|
};
|
|
|
|
// ============================================
|
|
// Step Definitions
|
|
// ============================================
|
|
var STEPS = [
|
|
{ id: 'browser_check', label: 'Browser' },
|
|
{ id: 'connect_device', label: 'Connect' },
|
|
{ id: 'provision_wifi', label: 'WiFi' },
|
|
{ id: 'flash_firmware', label: 'Flash' },
|
|
{ id: 'detect_node', label: 'Detect' },
|
|
{ id: 'calibrate', label: 'Calibrate' },
|
|
{ id: 'placement', label: 'Position' },
|
|
{ id: 'complete', label: 'Done' },
|
|
];
|
|
|
|
// ============================================
|
|
// Wizard State
|
|
// ============================================
|
|
var state = {
|
|
currentStepIndex: -1,
|
|
port: null,
|
|
nodeMAC: null,
|
|
knownMACs: [],
|
|
wifiSSID: '',
|
|
wifiPass: '',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
mothershipIP: '',
|
|
pollTimer: null,
|
|
calibrateTimer: null,
|
|
calibratePhase: 'idle',
|
|
ws: null,
|
|
csiHistory: [],
|
|
calibrationLinks: [], // unique link IDs seen during calibration
|
|
container: null,
|
|
reproveMode: false, // true when started via reprove(mac)
|
|
reproveMAC: null, // the specific MAC to re-provision
|
|
};
|
|
|
|
// ============================================
|
|
// State Persistence (sessionStorage)
|
|
// ============================================
|
|
function saveState() {
|
|
try {
|
|
sessionStorage.setItem(CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: state.currentStepIndex,
|
|
nodeMAC: state.nodeMAC,
|
|
knownMACs: state.knownMACs,
|
|
wifiSSID: state.wifiSSID,
|
|
wifiPass: state.wifiPass,
|
|
mothershipHost: state.mothershipHost,
|
|
mothershipPort: state.mothershipPort,
|
|
mothershipIP: state.mothershipIP,
|
|
}));
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
function loadState() {
|
|
try {
|
|
var raw = sessionStorage.getItem(CONFIG.storageKey);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
function clearState() {
|
|
try { sessionStorage.removeItem(CONFIG.storageKey); } catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// ============================================
|
|
// Serial Helpers
|
|
// ============================================
|
|
async function getAuthorizedPort() {
|
|
var ports = await navigator.serial.getPorts();
|
|
return ports.length > 0 ? ports[0] : null;
|
|
}
|
|
|
|
async function requestPort() {
|
|
try {
|
|
state.port = await navigator.serial.requestPort();
|
|
return state.port;
|
|
} catch (e) {
|
|
if (e.name === 'NotFoundError') {
|
|
throw new UserError(
|
|
'No device detected. Did you hold the BOOT button while plugging in? ' +
|
|
'Try again: hold BOOT, then plug in the USB cable.'
|
|
);
|
|
}
|
|
if (e.name === 'NotAllowedError') {
|
|
throw new UserError(
|
|
'Browser blocked USB access. Check your browser\'s site permissions ' +
|
|
'for this address and try again.'
|
|
);
|
|
}
|
|
if (e.name === 'NetworkError') {
|
|
throw new UserError(
|
|
'Another application is using this USB port. Close Arduino IDE, esptool, ' +
|
|
'or any other serial monitor and try again.'
|
|
);
|
|
}
|
|
throw new UserError(
|
|
'Could not select a device. Please make sure your ESP32-S3 is connected via USB.'
|
|
);
|
|
}
|
|
}
|
|
|
|
async function sendSerialJSON(port, data) {
|
|
var encoder = new TextEncoderStream();
|
|
var writableClosed = encoder.readable.pipeTo(port.writable);
|
|
var writer = encoder.writable.getWriter();
|
|
await writer.write(JSON.stringify(data) + '\n');
|
|
writer.close();
|
|
await writableClosed;
|
|
}
|
|
|
|
async function sendSerialJSONAndWaitForResponse(port, data, timeoutMs) {
|
|
timeoutMs = timeoutMs || 15000;
|
|
|
|
// Set up reader first
|
|
var decoder = new TextDecoderStream();
|
|
var readableClosed = port.readable.pipeTo(decoder.writable);
|
|
var reader = decoder.readable.getReader();
|
|
|
|
// Send the data
|
|
var encoder = new TextEncoderStream();
|
|
var writableClosed = encoder.readable.pipeTo(port.writable);
|
|
var writer = encoder.writable.getWriter();
|
|
await writer.write(JSON.stringify(data) + '\n');
|
|
writer.close();
|
|
await writableClosed;
|
|
|
|
// Wait for response with timeout
|
|
var buffer = '';
|
|
var startTime = Date.now();
|
|
var response = null;
|
|
|
|
try {
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
var result = await Promise.race([
|
|
reader.read(),
|
|
new Promise(function (_, reject) {
|
|
setTimeout(function () {
|
|
reject(new Error('Timeout waiting for device response'));
|
|
}, timeoutMs - (Date.now() - startTime));
|
|
})
|
|
]);
|
|
|
|
if (result.done) {
|
|
break;
|
|
}
|
|
|
|
buffer += result.value;
|
|
var newlineIndex = buffer.indexOf('\n');
|
|
if (newlineIndex !== -1) {
|
|
var line = buffer.substring(0, newlineIndex).trim();
|
|
if (line.length > 0) {
|
|
try {
|
|
response = JSON.parse(line);
|
|
break;
|
|
} catch (e) {
|
|
// Not valid JSON, continue reading
|
|
}
|
|
}
|
|
buffer = buffer.substring(newlineIndex + 1);
|
|
}
|
|
}
|
|
} finally {
|
|
reader.cancel();
|
|
try { await readableClosed; } catch (e) { /* ignore */ }
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async function closePort(port) {
|
|
try { await port.close(); } catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// ============================================
|
|
// User Error (non-technical error for display)
|
|
// ============================================
|
|
function UserError(message) {
|
|
this.name = 'UserError';
|
|
this.message = message;
|
|
}
|
|
UserError.prototype = Object.create(Error.prototype);
|
|
|
|
function isUserError(e) {
|
|
return e instanceof UserError || (e.name === 'UserError');
|
|
}
|
|
|
|
// ============================================
|
|
// HTML Helpers
|
|
// ============================================
|
|
function escapeAttr(s) {
|
|
return String(s || '').replace(/&/g, '&').replace(/"/g, '"')
|
|
.replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function formatMAC(bytes, offset) {
|
|
var parts = [];
|
|
for (var i = 0; i < 6; i++) {
|
|
parts.push(bytes[offset + i].toString(16).padStart(2, '0').toUpperCase());
|
|
}
|
|
return parts.join(':');
|
|
}
|
|
|
|
// ============================================
|
|
// Step Indicator
|
|
// ============================================
|
|
function renderStepIndicator() {
|
|
var el = document.getElementById('wizard-steps');
|
|
if (!el) return;
|
|
var html = '';
|
|
for (var i = 0; i < STEPS.length; i++) {
|
|
var cls = 'wizard-step-dot';
|
|
if (i < state.currentStepIndex) cls += ' completed';
|
|
else if (i === state.currentStepIndex) cls += ' active';
|
|
html += '<div class="' + cls + '">' + (i + 1) + '</div>';
|
|
if (i < STEPS.length - 1) {
|
|
var lineCls = 'wizard-step-line';
|
|
if (i < state.currentStepIndex) lineCls += ' completed';
|
|
html += '<div class="' + lineCls + '"></div>';
|
|
}
|
|
}
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
// ============================================
|
|
// Navigation Buttons
|
|
// ============================================
|
|
function renderNav(showBack, nextLabel, onNext, isPrimary) {
|
|
var nav = document.getElementById('wizard-nav');
|
|
if (!nav) return;
|
|
var html = '';
|
|
if (showBack) {
|
|
html += '<button class="wizard-btn wizard-btn-secondary" id="wizard-back">Back</button>';
|
|
}
|
|
html += '<button class="wizard-btn ' +
|
|
(isPrimary !== false ? 'wizard-btn-primary' : 'wizard-btn-secondary') +
|
|
'" id="wizard-next">' + (nextLabel || 'Next') + '</button>';
|
|
nav.innerHTML = html;
|
|
|
|
if (showBack) {
|
|
document.getElementById('wizard-back').addEventListener('click', function () {
|
|
goToStep(state.currentStepIndex - 1);
|
|
});
|
|
}
|
|
document.getElementById('wizard-next').addEventListener('click', onNext);
|
|
}
|
|
|
|
function hideNav() {
|
|
var nav = document.getElementById('wizard-nav');
|
|
if (nav) nav.innerHTML = '';
|
|
}
|
|
|
|
// ============================================
|
|
// Step Renderers
|
|
// ============================================
|
|
// Each renderer populates the content area and returns { cleanup: fn }
|
|
|
|
function renderBrowserCheck(contentEl) {
|
|
if (navigator.serial) {
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-center-msg">' +
|
|
'<div class="spinner"></div>' +
|
|
'<p>Checking browser compatibility...</p>' +
|
|
'</div>';
|
|
setTimeout(function () { goToStep(1); }, 400);
|
|
return { cleanup: function () { } };
|
|
}
|
|
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
'<div class="wizard-icon-large">⚠</div>' +
|
|
'<h2>Browser Not Supported</h2>' +
|
|
'<p>Please use <strong>Google Chrome</strong> or <strong>Microsoft Edge</strong> to use the setup wizard.</p>' +
|
|
'<p class="wizard-muted">Firefox and Safari do not support USB device access required for this wizard.</p>' +
|
|
'</div>';
|
|
hideNav();
|
|
return { cleanup: function () { } };
|
|
}
|
|
|
|
function renderConnectDevice(contentEl) {
|
|
var reproveBanner = state.reproveMode
|
|
? '<div class="wizard-reprove-banner">' +
|
|
'<strong>Re-provisioning ' + escapeAttr(state.reproveMAC || 'node') + '</strong> — ' +
|
|
'Firmware is already installed. Connect via USB to update the security token only.' +
|
|
'</div>'
|
|
: '';
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
reproveBanner +
|
|
'<h2>Connect Your ESP32-S3</h2>' +
|
|
'<p>Connect the ESP32-S3 to your computer using a USB cable.</p>' +
|
|
'<div class="esp32-illustration">' +
|
|
'<svg viewBox="0 0 200 120" width="200" height="120">' +
|
|
'<rect x="20" y="20" width="160" height="80" rx="4" fill="#2d5a27" stroke="#4a8a3f" stroke-width="1.5"/>' +
|
|
'<rect x="0" y="40" width="25" height="40" rx="3" fill="#888" stroke="#aaa" stroke-width="1"/>' +
|
|
'<rect x="2" y="42" width="21" height="36" rx="2" fill="#666"/>' +
|
|
'<rect x="155" y="15" width="25" height="35" rx="2" fill="#333" stroke="#555" stroke-width="1"/>' +
|
|
'<rect x="40" y="80" width="18" height="12" rx="2" fill="#4fc3f7" stroke="#29b6f6" stroke-width="1.5">' +
|
|
'<animate attributeName="opacity" values="1;0.5;1" dur="2s" repeatCount="indefinite"/>' +
|
|
'</rect>' +
|
|
'<text x="49" y="106" text-anchor="middle" fill="#4fc3f7" font-size="8" font-weight="bold">BOOT</text>' +
|
|
'<rect x="70" y="80" width="18" height="12" rx="2" fill="#666" stroke="#888" stroke-width="1"/>' +
|
|
'<text x="79" y="106" text-anchor="middle" fill="#888" font-size="8">RST</text>' +
|
|
'<circle cx="140" cy="85" r="3" fill="#f44336">' +
|
|
'<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite"/>' +
|
|
'</circle>' +
|
|
'<rect x="85" y="35" width="35" height="35" rx="2" fill="#1a1a1a" stroke="#333" stroke-width="1"/>' +
|
|
'<text x="102" y="56" text-anchor="middle" fill="#555" font-size="7">ESP32</text>' +
|
|
buildPins(30, 18, 15) +
|
|
buildPins(30, 97, 15) +
|
|
'</svg>' +
|
|
'</div>' +
|
|
'<p class="wizard-muted">Hold the <strong style="color:#4fc3f7">BOOT</strong> button while plugging in if the device does not appear.</p>' +
|
|
'<div id="connect-error" class="wizard-error" style="display:none"></div>' +
|
|
'</div>';
|
|
|
|
renderNav(false, 'Select Device', function () {
|
|
document.getElementById('connect-error').style.display = 'none';
|
|
var btn = document.getElementById('wizard-next');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Waiting for device...';
|
|
|
|
requestPort().then(function () {
|
|
saveState();
|
|
goToStep(state.currentStepIndex + 1);
|
|
}).catch(function (e) {
|
|
var errEl = document.getElementById('connect-error');
|
|
errEl.style.display = 'block';
|
|
errEl.textContent = isUserError(e) ? e.message : 'Could not select device. Please try again.';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Select Device';
|
|
});
|
|
});
|
|
|
|
return {
|
|
cleanup: function () { }
|
|
};
|
|
}
|
|
|
|
function buildPins(startX, y, count) {
|
|
var html = '';
|
|
for (var i = 0; i < count; i++) {
|
|
html += '<rect x="' + (startX + i * 9) + '" y="' + y + '" width="3" height="5" fill="#c8a84e"/>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
var BOOTLOADER_SVG =
|
|
'<svg viewBox="0 0 200 120" width="180" height="108" style="display:block;margin:8px auto">' +
|
|
'<rect x="20" y="20" width="160" height="80" rx="4" fill="#2d5a27" stroke="#4a8a3f" stroke-width="1.5"/>' +
|
|
'<rect x="0" y="40" width="25" height="40" rx="3" fill="#888" stroke="#aaa" stroke-width="1"/>' +
|
|
'<rect x="2" y="42" width="21" height="36" rx="2" fill="#666"/>' +
|
|
'<rect x="155" y="15" width="25" height="35" rx="2" fill="#333" stroke="#555" stroke-width="1"/>' +
|
|
'<rect x="40" y="80" width="18" height="12" rx="2" fill="#4fc3f7" stroke="#29b6f6" stroke-width="2">' +
|
|
'<animate attributeName="opacity" values="1;0.4;1" dur="1s" repeatCount="indefinite"/>' +
|
|
'</rect>' +
|
|
'<text x="49" y="106" text-anchor="middle" fill="#4fc3f7" font-size="8" font-weight="bold">BOOT</text>' +
|
|
'<rect x="70" y="80" width="18" height="12" rx="2" fill="#f44336" stroke="#e53935" stroke-width="2">' +
|
|
'<animate attributeName="opacity" values="1;0.4;1" dur="1.4s" repeatCount="indefinite"/>' +
|
|
'</rect>' +
|
|
'<text x="79" y="106" text-anchor="middle" fill="#f44336" font-size="8" font-weight="bold">RST</text>' +
|
|
'<rect x="85" y="35" width="35" height="35" rx="2" fill="#1a1a1a" stroke="#333" stroke-width="1"/>' +
|
|
'<text x="102" y="56" text-anchor="middle" fill="#555" font-size="7">ESP32</text>' +
|
|
buildPins(30, 18, 15) + buildPins(30, 97, 15) +
|
|
'</svg>';
|
|
|
|
function renderBootloaderHelp(retryCount) {
|
|
var escalated = retryCount >= 2;
|
|
return '<div class="wizard-bootloader-help" style="background:#1a2a1a;border:1px solid #4fc3f7;border-radius:6px;padding:12px;margin:12px 0;text-align:center">' +
|
|
'<p style="margin:0 0 8px;color:#4fc3f7;font-weight:bold">' + (escalated ? '⚠ Still not working?' : 'Device not in download mode') + '</p>' +
|
|
(escalated
|
|
? '<p style="font-size:12px;color:#aaa;margin:0 0 8px">Try a different USB cable (data cables only, not charge-only). If using a USB hub, connect directly to your computer.</p>'
|
|
: '<p style="font-size:12px;color:#ccc;margin:0 0 8px">Hold <strong style="color:#4fc3f7">BOOT</strong>, press & release <strong style="color:#f44336">RST</strong>, then release <strong style="color:#4fc3f7">BOOT</strong>.</p>') +
|
|
BOOTLOADER_SVG +
|
|
'</div>';
|
|
}
|
|
|
|
// In reprove mode, skip firmware flash and send provisioning payload over serial.
|
|
function renderReprovision(contentEl) {
|
|
var cancelled = false;
|
|
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
'<h2>Re-provision Node</h2>' +
|
|
'<p>Sending updated security credentials to your ESP32-S3...</p>' +
|
|
'<p class="wizard-muted">Press the <strong style="color:#f44336">RST</strong> button on your device if the progress stalls.</p>' +
|
|
'<div id="reprovision-progress-bar" style="margin:16px 0">' +
|
|
' <div class="progress-bar"><div class="progress-fill" id="reprovision-progress-fill" style="width:0%"></div></div>' +
|
|
'</div>' +
|
|
'<p id="reprovision-status-text" style="display:none;margin:8px 0;font-size:13px;color:#80cbc4"></p>' +
|
|
'<div id="reprovision-recovery" style="display:none"></div>' +
|
|
'<details id="reprovision-log-details" style="margin-top:16px;font-size:12px">' +
|
|
' <summary style="cursor:pointer;color:#546e7a">Show log</summary>' +
|
|
' <div id="reprovision-log-body" style="background:#0a0e13;border:1px solid #263238;border-radius:4px;' +
|
|
'padding:8px;margin-top:4px;max-height:160px;overflow-y:auto;font-family:monospace"></div>' +
|
|
'</details>' +
|
|
'</div>';
|
|
hideNav();
|
|
|
|
function appendLog(level, msg) {
|
|
var logEl = document.getElementById('reprovision-log-body');
|
|
if (!logEl) return;
|
|
var ts = new Date().toISOString().slice(11, 23);
|
|
var color = level === 'error' ? '#ef9a9a' : level === 'warn' ? '#ffe082' : '#b0bec5';
|
|
var line = document.createElement('div');
|
|
line.style.cssText = 'font-size:11px;color:' + color + ';word-break:break-all;margin:1px 0';
|
|
line.textContent = '[' + ts + '] ' + msg;
|
|
logEl.appendChild(line);
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
function setStatus(msg, color) {
|
|
var el = document.getElementById('reprovision-status-text');
|
|
if (!el) return;
|
|
el.style.display = msg ? 'block' : 'none';
|
|
el.style.color = color || '#80cbc4';
|
|
el.textContent = msg;
|
|
}
|
|
|
|
function setProgress(pct) {
|
|
var fill = document.getElementById('reprovision-progress-fill');
|
|
if (fill) fill.style.width = pct + '%';
|
|
}
|
|
|
|
async function doReprovision() {
|
|
try {
|
|
// Fetch provisioning payload with the known MAC so the server derives the correct token.
|
|
setStatus('Generating credentials...');
|
|
setProgress(10);
|
|
appendLog('log', 'POST ' + CONFIG.provisioningEndpoint + ' with mac=' + (state.reproveMAC || '(unknown)'));
|
|
|
|
var resp = await fetch(CONFIG.provisioningEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
wifi_ssid: state.wifiSSID,
|
|
wifi_pass: state.wifiPass,
|
|
mac: state.reproveMAC || '',
|
|
ms_ip: state.mothershipIP || ''
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error('Provisioning server error: HTTP ' + resp.status);
|
|
var payload = await resp.json();
|
|
if (state.mothershipHost) payload.ms_mdns = state.mothershipHost;
|
|
if (state.mothershipPort) payload.ms_port = state.mothershipPort;
|
|
if (state.mothershipIP) payload.ms_ip = state.mothershipIP;
|
|
appendLog('log', 'Payload ready — node_id=' + (payload.node_id || '(none)'));
|
|
|
|
setProgress(30);
|
|
setStatus('Waiting for device...');
|
|
appendLog('log', 'Sending payload over serial...');
|
|
|
|
var mac = await sendPayloadOverSerial(payload,
|
|
function (level, msg) { appendLog(level, msg); },
|
|
function (msg) { setStatus(msg); }
|
|
);
|
|
|
|
if (cancelled) return;
|
|
|
|
setProgress(100);
|
|
setStatus('✓ Credentials updated!', '#a5d6a7');
|
|
appendLog('log', 'Re-provisioning complete — MAC: ' + (mac || 'unknown'));
|
|
state.nodeMAC = mac || state.reproveMAC;
|
|
|
|
// Snapshot existing nodes for the detect step.
|
|
try {
|
|
var nodesResp = await fetch(CONFIG.nodesEndpoint);
|
|
var nodes = await nodesResp.json();
|
|
state.knownMACs = (nodes || []).map(function (n) { return n.mac; });
|
|
} catch (_) {}
|
|
|
|
saveState();
|
|
setTimeout(function () { goToStep(state.currentStepIndex + 1); }, 1200);
|
|
|
|
} catch (e) {
|
|
if (cancelled) return;
|
|
appendLog('error', 'Re-provision failed: ' + (e.message || String(e)));
|
|
setStatus('');
|
|
setProgress(0);
|
|
document.getElementById('reprovision-progress-bar').style.display = 'none';
|
|
document.getElementById('reprovision-log-details').open = true;
|
|
|
|
var recovery = document.getElementById('reprovision-recovery');
|
|
recovery.style.display = 'block';
|
|
recovery.innerHTML =
|
|
'<div class="wizard-error" style="margin-bottom:12px">' +
|
|
(isUserError(e) ? e.message : 'Re-provisioning failed. Check that the device is connected and try again.') +
|
|
'</div>' +
|
|
'<div style="text-align:center">' +
|
|
'<button class="wizard-btn wizard-btn-primary" id="reprove-retry-btn">Try Again</button>' +
|
|
'</div>';
|
|
document.getElementById('reprove-retry-btn').addEventListener('click', function () {
|
|
recovery.style.display = 'none';
|
|
recovery.innerHTML = '';
|
|
document.getElementById('reprovision-progress-bar').style.display = 'block';
|
|
setProgress(0);
|
|
doReprovision();
|
|
});
|
|
}
|
|
}
|
|
|
|
doReprovision();
|
|
return {
|
|
cleanup: function () { cancelled = true; }
|
|
};
|
|
}
|
|
|
|
function renderFlashFirmware(contentEl) {
|
|
// In reprove mode, skip the firmware flash and go straight to serial provisioning.
|
|
if (state.reproveMode) {
|
|
return renderReprovision(contentEl);
|
|
}
|
|
var flashRetryCount = 0;
|
|
var cancelled = false;
|
|
var origConsole = { log: console.log, warn: console.warn, error: console.error };
|
|
|
|
function appendLog(level, args) {
|
|
var msg = Array.prototype.slice.call(args).map(function (a) {
|
|
try { return (typeof a === 'object') ? JSON.stringify(a) : String(a); } catch (e) { return String(a); }
|
|
}).join(' ');
|
|
var ts = new Date().toISOString().slice(11, 23);
|
|
var logEl = document.getElementById('flash-log-body');
|
|
if (logEl) {
|
|
var color = level === 'error' ? '#ef9a9a' : level === 'warn' ? '#ffe082' : '#b0bec5';
|
|
var line = document.createElement('div');
|
|
line.style.cssText = 'font-size:11px;color:' + color + ';word-break:break-all;margin:1px 0';
|
|
line.textContent = '[' + ts + '] ' + msg;
|
|
logEl.appendChild(line);
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function patchConsole() {
|
|
['log', 'warn', 'error'].forEach(function (m) {
|
|
console[m] = function () { origConsole[m].apply(console, arguments); appendLog(m, arguments); };
|
|
});
|
|
}
|
|
|
|
function restoreConsole() {
|
|
['log', 'warn', 'error'].forEach(function (m) { console[m] = origConsole[m]; });
|
|
}
|
|
|
|
function setStatus(msg, color) {
|
|
var el = document.getElementById('flash-status-text');
|
|
if (!el) { return; }
|
|
el.style.display = msg ? 'block' : 'none';
|
|
el.style.color = color || '#80cbc4';
|
|
el.textContent = msg;
|
|
}
|
|
|
|
function setProgress(pct) {
|
|
var fill = document.getElementById('flash-progress-fill');
|
|
if (fill) { fill.style.width = pct + '%'; }
|
|
}
|
|
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
'<h2>Flash Firmware</h2>' +
|
|
'<p>Flashing Spaxel firmware to your ESP32-S3...</p>' +
|
|
'<div id="flash-progress-bar" style="margin:16px 0">' +
|
|
' <div class="progress-bar"><div class="progress-fill" id="flash-progress-fill" style="width:0%"></div></div>' +
|
|
'</div>' +
|
|
'<p id="flash-status-text" style="display:none;margin:8px 0;font-size:13px;color:#80cbc4"></p>' +
|
|
'<div id="flash-recovery" style="display:none"></div>' +
|
|
'<details id="flash-log-details" style="margin-top:16px;font-size:12px">' +
|
|
' <summary style="cursor:pointer;color:#546e7a">Show install log</summary>' +
|
|
' <div id="flash-log-body" style="background:#0a0e13;border:1px solid #263238;border-radius:4px;' +
|
|
'padding:8px;margin-top:4px;max-height:160px;overflow-y:auto;font-family:monospace"></div>' +
|
|
'</details>' +
|
|
'<p style="margin-top:20px;text-align:center">' +
|
|
' <a id="flash-back-link" href="#" style="font-size:12px;color:#546e7a;text-decoration:none">← Back to Connect</a>' +
|
|
'</p>' +
|
|
'</div>';
|
|
hideNav();
|
|
patchConsole();
|
|
document.getElementById('flash-back-link').addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
cancelled = true;
|
|
restoreConsole();
|
|
var connectIdx = STEPS.findIndex(function (s) { return s.id === 'connect_device'; });
|
|
goToStep(connectIdx >= 0 ? connectIdx : state.currentStepIndex - 1);
|
|
});
|
|
|
|
// Uses vendored esptool-js (dashboard/js/esptool-bundle.js) loaded via dynamic import.
|
|
// Flashing starts automatically — no button click required.
|
|
async function doFlash() {
|
|
var transport = null;
|
|
var flashSucceeded = false;
|
|
try {
|
|
// 1. Fetch firmware manifest
|
|
setStatus('Fetching firmware info...');
|
|
appendLog('log', ['Fetching /api/firmware/manifest']);
|
|
var manifestResp = await fetch('/api/firmware/manifest');
|
|
if (!manifestResp.ok) { throw new Error('Manifest fetch failed: ' + manifestResp.status); }
|
|
var manifest = await manifestResp.json();
|
|
var builds = manifest.builds || [];
|
|
var build = builds.find(function (b) { return b.chipFamily === 'ESP32-S3'; }) || builds[0];
|
|
if (!build || !build.parts || !build.parts.length) { throw new Error('No firmware found in manifest'); }
|
|
var part = build.parts[0];
|
|
var firmwareUrl = part.path;
|
|
var offset = typeof part.offset === 'number' ? part.offset : 0;
|
|
appendLog('log', ['Firmware: ' + firmwareUrl + ' @ 0x' + offset.toString(16)]);
|
|
|
|
// 2. Download firmware binary
|
|
setStatus('Downloading firmware...');
|
|
setProgress(3);
|
|
var binResp = await fetch(firmwareUrl);
|
|
if (!binResp.ok) { throw new Error('Firmware download failed: ' + binResp.status); }
|
|
var arrayBuffer = await binResp.arrayBuffer();
|
|
var firmwareData = new Uint8Array(arrayBuffer);
|
|
appendLog('log', ['Downloaded ' + firmwareData.length + ' bytes']);
|
|
setProgress(8);
|
|
|
|
// 3. Load esptool-js and connect to device
|
|
setStatus('Connecting to device...');
|
|
var flashLib = await import('/js/esptool-bundle.js');
|
|
var ESPLoader = flashLib.ESPLoader;
|
|
var Transport = flashLib.Transport;
|
|
|
|
if (!state.port) { throw new Error('No serial port selected — go back to Connect step'); }
|
|
transport = new Transport(state.port, false);
|
|
var loader = new ESPLoader({
|
|
transport: transport,
|
|
baudrate: 115200,
|
|
terminal: {
|
|
clean: function () {},
|
|
writeLine: function (s) { appendLog('log', [s]); },
|
|
write: function (s) { appendLog('log', [s]); }
|
|
}
|
|
});
|
|
|
|
if (cancelled) { return; }
|
|
var chip = await loader.main();
|
|
appendLog('log', ['Connected: ' + chip]);
|
|
|
|
// Validate detected chip family against manifest
|
|
var expectedFamily = (build.chipFamily || '').toUpperCase().replace(/-/g, '');
|
|
if (expectedFamily && !chip.toUpperCase().replace(/-/g, '').includes(expectedFamily)) {
|
|
throw new UserError(
|
|
'Connected device (' + chip + ') is not supported by this firmware. ' +
|
|
'This image requires an ' + build.chipFamily + '. ' +
|
|
'Please connect the correct device.'
|
|
);
|
|
}
|
|
setProgress(12);
|
|
|
|
// 4. Flash (progress 12% → 80%)
|
|
setStatus('Erasing and flashing...');
|
|
document.getElementById('flash-log-details').open = true;
|
|
|
|
await loader.writeFlash({
|
|
fileArray: [{ data: firmwareData, address: offset }],
|
|
flashSize: '4MB',
|
|
flashMode: 'dio',
|
|
flashFreq: '80m',
|
|
eraseAll: false,
|
|
compress: true,
|
|
reportProgress: function (fileIndex, written, total) {
|
|
if (cancelled) { return; }
|
|
var pct = 12 + Math.round(written / total * 68);
|
|
setProgress(pct);
|
|
setStatus('Flashing... ' + Math.round(written / total * 100) + '%');
|
|
}
|
|
});
|
|
|
|
await transport.disconnect();
|
|
transport = null;
|
|
flashSucceeded = true;
|
|
setProgress(80);
|
|
appendLog('log', ['Flash complete — device rebooting']);
|
|
|
|
if (cancelled) { return; }
|
|
|
|
// 5. Provision (progress 80% → 100%)
|
|
// Device reboots after flash and opens its provisioning window.
|
|
// Send WiFi + mothership config over serial immediately.
|
|
setStatus('Configuring device...');
|
|
|
|
var provLog = function (level, msg) {
|
|
appendLog(level, [msg]);
|
|
};
|
|
var mac = await doProvision(provLog, setStatus, setProgress);
|
|
|
|
if (cancelled) { return; }
|
|
setProgress(100);
|
|
setStatus('✓ Device configured!', '#a5d6a7');
|
|
appendLog('log', ['Provisioning complete — MAC: ' + (mac || 'unknown')]);
|
|
restoreConsole();
|
|
// Snapshot existing nodes before advancing so detect step knows what's new
|
|
try {
|
|
var nodesResp = await fetch(CONFIG.nodesEndpoint);
|
|
var nodes = await nodesResp.json();
|
|
state.knownMACs = (nodes || []).map(function (n) { return n.mac; });
|
|
} catch (_) {}
|
|
saveState();
|
|
setTimeout(function () { goToStep(state.currentStepIndex + 1); }, 1200);
|
|
|
|
} catch (e) {
|
|
if (cancelled) { return; }
|
|
if (transport) { try { await transport.disconnect(); } catch (_) {} transport = null; }
|
|
|
|
flashRetryCount++;
|
|
restoreConsole();
|
|
appendLog('error', ['Flash failed: ' + (e.message || String(e))]);
|
|
setStatus('');
|
|
setProgress(0);
|
|
document.getElementById('flash-progress-bar').style.display = 'none';
|
|
document.getElementById('flash-log-details').open = true;
|
|
|
|
var recovery = document.getElementById('flash-recovery');
|
|
recovery.style.display = 'block';
|
|
var helpHtml;
|
|
if (flashSucceeded) {
|
|
// Device flashed OK but provisioning failed — don't tell user about BOOT button
|
|
helpHtml = '<div class="wizard-bootloader-help" style="background:#1a2a1a;border:1px solid #f44336;border-radius:6px;padding:12px;margin:12px 0;text-align:center">' +
|
|
'<p style="margin:0 0 8px;color:#f44336;font-weight:bold">Provisioning failed</p>' +
|
|
'<p style="font-size:12px;color:#ccc;margin:0 0 8px">Firmware flashed successfully. ' +
|
|
'Unplug and replug the USB cable, then click Try Again to send the configuration.</p>' +
|
|
'</div>';
|
|
} else {
|
|
helpHtml = renderBootloaderHelp(flashRetryCount);
|
|
}
|
|
recovery.innerHTML = helpHtml +
|
|
'<div style="text-align:center;margin-top:8px">' +
|
|
'<button class="wizard-btn wizard-btn-primary" id="flash-retry-btn">Try Again</button>' +
|
|
'</div>';
|
|
document.getElementById('flash-retry-btn').addEventListener('click', function () {
|
|
recovery.style.display = 'none';
|
|
recovery.innerHTML = '';
|
|
document.getElementById('flash-progress-bar').style.display = 'block';
|
|
setProgress(0);
|
|
patchConsole();
|
|
doFlash();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Runs after firmware flash: fetches provisioning payload from server (or
|
|
// builds client-side fallback) and sends it over serial while the device's
|
|
// boot provisioning window is open.
|
|
async function doProvision(provLog, setStatus, setProgress) {
|
|
var ssid = state.wifiSSID;
|
|
var pass = state.wifiPass;
|
|
var msHost = state.mothershipHost;
|
|
var msPort = state.mothershipPort;
|
|
|
|
// Fetch server payload (generates node_id + token).
|
|
// Race it against a 5s timeout so we don't stall the provisioning window.
|
|
var payload = null;
|
|
try {
|
|
provLog('log', 'POST ' + CONFIG.provisioningEndpoint);
|
|
var fetchPromise = fetch(CONFIG.provisioningEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ wifi_ssid: ssid, wifi_pass: pass, ms_ip: state.mothershipIP || '' }),
|
|
});
|
|
var timeoutPromise = new Promise(function (_, reject) {
|
|
setTimeout(function () { reject(new Error('timeout')); }, 5000);
|
|
});
|
|
var resp = await Promise.race([fetchPromise, timeoutPromise]);
|
|
if (!resp.ok) { throw new Error('HTTP ' + resp.status); }
|
|
payload = await resp.json();
|
|
if (msHost) payload.ms_mdns = msHost;
|
|
if (msPort) payload.ms_port = msPort;
|
|
if (state.mothershipIP) payload.ms_ip = state.mothershipIP;
|
|
provLog('log', 'Server payload: node_id=' + (payload.node_id || '(none)'));
|
|
} catch (err) {
|
|
provLog('warn', 'Mothership unreachable (' + (err.message || err) + '), using client-side payload');
|
|
payload = {
|
|
wifi_ssid: ssid,
|
|
wifi_pass: pass,
|
|
node_id: crypto.randomUUID ? crypto.randomUUID() :
|
|
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
var r = Math.random() * 16 | 0;
|
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
}),
|
|
node_token: '',
|
|
ms_mdns: msHost || window.location.hostname,
|
|
ms_port: msPort,
|
|
ms_ip: state.mothershipIP || '',
|
|
debug: false,
|
|
};
|
|
}
|
|
setProgress(85);
|
|
|
|
var addProvLog = function (level, msg) { provLog(level, msg); };
|
|
var setProvStatus = function (msg) { setStatus(msg); };
|
|
var mac = await sendPayloadOverSerial(payload, addProvLog, setProvStatus);
|
|
setProgress(95);
|
|
return mac;
|
|
}
|
|
|
|
doFlash();
|
|
return { cleanup: function () { cancelled = true; restoreConsole(); } };
|
|
}
|
|
|
|
function renderProvisionWifi(contentEl) {
|
|
// Auto-populate ms_ip if the browser is accessing the mothership by IP directly
|
|
if (!state.mothershipIP) {
|
|
var host = window.location.hostname;
|
|
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
|
|
state.mothershipIP = host;
|
|
}
|
|
}
|
|
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
'<h2>Configure WiFi</h2>' +
|
|
'<p>Enter your WiFi credentials. These will be flashed to the device in the next step.</p>' +
|
|
'<form id="wifi-form" class="wizard-form">' +
|
|
'<div class="form-group">' +
|
|
'<label for="wifi-ssid">WiFi Network Name (SSID)</label>' +
|
|
'<input type="text" id="wifi-ssid" required placeholder="MyWiFi" value="' + escapeAttr(state.wifiSSID) + '" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false">' +
|
|
'</div>' +
|
|
'<div class="form-group">' +
|
|
'<label for="wifi-pass">WiFi Password</label>' +
|
|
'<input type="password" id="wifi-pass" placeholder="Password" value="' + escapeAttr(state.wifiPass) + '" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">' +
|
|
'</div>' +
|
|
'<details class="wizard-details">' +
|
|
'<summary>Advanced: Network Troubleshooting</summary>' +
|
|
'<div class="form-group" style="margin-top:8px">' +
|
|
'<label for="ms-host">Mothership Host <span class="wizard-muted">(leave blank to use ' + escapeAttr(window.location.hostname) + ')</span></label>' +
|
|
'<input type="text" id="ms-host" placeholder="' + escapeAttr(window.location.hostname) + '" value="' + escapeAttr(state.mothershipHost) + '" autocomplete="off">' +
|
|
'</div>' +
|
|
'<div class="form-group">' +
|
|
'<label for="ms-port">Port</label>' +
|
|
'<input type="number" id="ms-port" value="' + state.mothershipPort + '" min="1" max="65535">' +
|
|
'</div>' +
|
|
'<div class="form-group">' +
|
|
'<label for="ms-ip">Mothership IP <span class="wizard-muted">(for networks where mDNS is blocked)</span></label>' +
|
|
'<input type="text" id="ms-ip" placeholder="e.g. 192.168.1.100" value="' + escapeAttr(state.mothershipIP) + '" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">' +
|
|
'<p class="wizard-muted" style="font-size:11px;margin-top:4px">If your network blocks mDNS (enterprise WiFi, some mesh routers), enter the mothership\'s IP address here. Leave blank to use automatic discovery.</p>' +
|
|
'</div>' +
|
|
'</details>' +
|
|
'<div id="provision-error" class="wizard-error" style="display:none"></div>' +
|
|
'<button type="submit" class="wizard-btn wizard-btn-primary">Next: Flash Firmware</button>' +
|
|
'</form>' +
|
|
'</div>';
|
|
|
|
hideNav();
|
|
|
|
document.getElementById('wifi-form').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var ssid = document.getElementById('wifi-ssid').value.trim();
|
|
if (!ssid) {
|
|
showFormError('provision-error', 'Please enter a WiFi network name.');
|
|
return;
|
|
}
|
|
state.wifiSSID = ssid;
|
|
state.wifiPass = document.getElementById('wifi-pass').value;
|
|
state.mothershipHost = document.getElementById('ms-host').value.trim();
|
|
state.mothershipPort = parseInt(document.getElementById('ms-port').value, 10) || 8080;
|
|
state.mothershipIP = document.getElementById('ms-ip').value.trim();
|
|
saveState();
|
|
goToStep(state.currentStepIndex + 1);
|
|
});
|
|
|
|
return { cleanup: function () { } };
|
|
}
|
|
|
|
function provisionAndSend(ssid, pass, msHost, msPort, addProvLog, setProvStatus) {
|
|
addProvLog = addProvLog || function () {};
|
|
setProvStatus = setProvStatus || function () {};
|
|
|
|
addProvLog('log', 'POST ' + CONFIG.provisioningEndpoint + ' — requesting node credentials from mothership');
|
|
setProvStatus('Contacting mothership...');
|
|
|
|
// Try server-side provisioning first (generates proper node_id and token)
|
|
return fetch(CONFIG.provisioningEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ wifi_ssid: ssid, wifi_pass: pass, ms_ip: state.mothershipIP || '' }),
|
|
})
|
|
.then(function (r) {
|
|
addProvLog('log', 'Mothership response: HTTP ' + r.status);
|
|
if (!r.ok) throw new Error('provisioning server error: HTTP ' + r.status);
|
|
return r.json();
|
|
})
|
|
.then(function (payload) {
|
|
// Apply user overrides for mothership address
|
|
if (msHost) payload.ms_mdns = msHost;
|
|
if (msPort) payload.ms_port = msPort;
|
|
if (state.mothershipIP) payload.ms_ip = state.mothershipIP;
|
|
addProvLog('log', 'Payload ready — node_id=' + (payload.node_id || '(none)') + ' ms_mdns=' + (payload.ms_mdns || '(none)'));
|
|
setProvStatus('Sending configuration to device...');
|
|
return sendPayloadOverSerial(payload, addProvLog, setProvStatus);
|
|
})
|
|
.catch(function (err) {
|
|
addProvLog('warn', 'Mothership unreachable (' + (err.message || err) + '), falling back to client-side payload');
|
|
setProvStatus('Sending configuration to device (offline mode)...');
|
|
// Fallback: assemble payload client-side
|
|
var payload = {
|
|
version: 1,
|
|
wifi_ssid: ssid,
|
|
wifi_pass: pass,
|
|
node_id: crypto.randomUUID ? crypto.randomUUID() :
|
|
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
var r = Math.random() * 16 | 0;
|
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
}),
|
|
node_token: '',
|
|
ms_mdns: msHost || window.location.hostname,
|
|
ms_port: msPort,
|
|
ms_ip: state.mothershipIP || '',
|
|
debug: false,
|
|
};
|
|
addProvLog('log', 'Fallback payload — node_id=' + payload.node_id);
|
|
return sendPayloadOverSerial(payload, addProvLog, setProvStatus);
|
|
});
|
|
}
|
|
|
|
async function sendPayloadOverSerial(payload, addProvLog, setProvStatus) {
|
|
addProvLog = addProvLog || function () {};
|
|
setProvStatus = setProvStatus || function () {};
|
|
|
|
// Firmware expects {"provision": {...}} format
|
|
var wrappedPayload = { provision: payload };
|
|
|
|
addProvLog('log', 'Looking up serial port (state.port=' + (state.port ? 'set' : 'null') + ')');
|
|
var port = state.port || await getAuthorizedPort();
|
|
if (!port) {
|
|
addProvLog('error', 'No serial port available');
|
|
throw new UserError('No device found. Please go back to Connect and select your ESP32-S3 again.');
|
|
}
|
|
addProvLog('log', 'Port found — opening at ' + CONFIG.serialBaudRate + ' baud');
|
|
|
|
// The port may be closed (esptool closes it after flashing). Retry while the device
|
|
// is still in the middle of its USB re-enumeration after the reset.
|
|
var opened = false;
|
|
for (var attempt = 0; attempt < 5; attempt++) {
|
|
try {
|
|
await port.open({ baudRate: CONFIG.serialBaudRate });
|
|
opened = true;
|
|
addProvLog('log', 'Port opened on attempt ' + (attempt + 1));
|
|
break;
|
|
} catch (e) {
|
|
if (e && (e.message || '').toLowerCase().includes('already open')) {
|
|
opened = true;
|
|
addProvLog('log', 'Port was already open — proceeding');
|
|
break;
|
|
}
|
|
addProvLog('warn', 'Open attempt ' + (attempt + 1) + ' failed: ' + (e.message || e));
|
|
if (attempt < 4) {
|
|
setProvStatus('Waiting for device to boot... (attempt ' + (attempt + 2) + '/5)');
|
|
await new Promise(function (r) { setTimeout(r, 1000); });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!opened) {
|
|
addProvLog('error', 'Could not open port after 5 attempts');
|
|
throw new UserError('Could not open serial port. Unplug and replug the USB cable, then try again.');
|
|
}
|
|
|
|
// Set up bidirectional streams. The reader is opened immediately so we don't
|
|
// miss the "SPAXEL READY" line that the firmware prints at boot.
|
|
var decoder = new TextDecoderStream();
|
|
var readableClosed = port.readable.pipeTo(decoder.writable);
|
|
var reader = decoder.readable.getReader();
|
|
|
|
var encoder = new TextEncoderStream();
|
|
var writableClosed = encoder.readable.pipeTo(port.writable);
|
|
var writer = encoder.writable.getWriter();
|
|
|
|
var mac = null;
|
|
try {
|
|
// Phase 1: wait for "SPAXEL READY <MAC>" from the firmware (up to 30 s).
|
|
// The firmware prints this immediately after its UART driver initialises, a
|
|
// few seconds after the reset that follows the flash. If we send the payload
|
|
// before this line appears the bytes are lost because the device UART isn't
|
|
// ready yet.
|
|
setProvStatus('Waiting for device to boot...');
|
|
addProvLog('log', 'Waiting for SPAXEL READY signal (up to 30 s)...');
|
|
|
|
var buffer = '';
|
|
var readyReceived = false;
|
|
var readyDeadline = Date.now() + 30000;
|
|
|
|
outer: while (Date.now() < readyDeadline) {
|
|
var remaining = readyDeadline - Date.now();
|
|
var result;
|
|
try {
|
|
result = await Promise.race([
|
|
reader.read(),
|
|
new Promise(function (_, reject) {
|
|
setTimeout(function () { reject(new Error('timeout')); }, remaining + 50);
|
|
})
|
|
]);
|
|
} catch (e) {
|
|
break; // deadline expired
|
|
}
|
|
if (result.done) break;
|
|
buffer += result.value;
|
|
|
|
var nl;
|
|
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
var line = buffer.substring(0, nl).trim();
|
|
buffer = buffer.substring(nl + 1);
|
|
if (line) addProvLog('log', 'Device: ' + line);
|
|
if (line.startsWith('SPAXEL READY')) {
|
|
readyReceived = true;
|
|
// Extract MAC that the firmware appends after the keyword
|
|
var parts = line.split(' ');
|
|
if (parts.length >= 3) mac = parts[parts.length - 1];
|
|
break outer;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!readyReceived) {
|
|
throw new UserError(
|
|
'Device did not become ready after flashing. ' +
|
|
'Unplug and replug the USB cable, then try again.'
|
|
);
|
|
}
|
|
|
|
addProvLog('log', 'SPAXEL READY received (MAC: ' + (mac || 'unknown') + ') — sending payload');
|
|
setProvStatus('Sending configuration to device...');
|
|
addProvLog('log', 'Payload: ' + JSON.stringify(wrappedPayload).substring(0, 120) + '...');
|
|
|
|
// Phase 2: send the JSON payload now that the firmware is listening.
|
|
await writer.write(JSON.stringify(wrappedPayload) + '\n');
|
|
writer.close();
|
|
await writableClosed;
|
|
|
|
// Phase 3: wait for the firmware's JSON acknowledgment (up to 10 s).
|
|
setProvStatus('Waiting for device acknowledgment...');
|
|
var response = null;
|
|
var respDeadline = Date.now() + 10000;
|
|
|
|
outer2: while (Date.now() < respDeadline) {
|
|
var remaining = respDeadline - Date.now();
|
|
var result;
|
|
try {
|
|
result = await Promise.race([
|
|
reader.read(),
|
|
new Promise(function (_, reject) {
|
|
setTimeout(function () { reject(new Error('timeout')); }, remaining + 50);
|
|
})
|
|
]);
|
|
} catch (e) {
|
|
break;
|
|
}
|
|
if (result.done) break;
|
|
buffer += result.value;
|
|
|
|
var nl;
|
|
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
var line = buffer.substring(0, nl).trim();
|
|
buffer = buffer.substring(nl + 1);
|
|
if (line) addProvLog('log', 'Device: ' + line);
|
|
if (line.length > 0) {
|
|
try {
|
|
response = JSON.parse(line);
|
|
break outer2;
|
|
} catch (e) { /* not JSON — keep reading */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
addProvLog('log', 'Serial response: ' + (response ? JSON.stringify(response) : '(none — timeout)'));
|
|
|
|
if (!response) {
|
|
throw new UserError(
|
|
'No response from device after sending configuration. ' +
|
|
'The provisioning window is open for 2 minutes after first boot.'
|
|
);
|
|
}
|
|
if (response.ok === false) {
|
|
var errorMsg = response.error || 'Unknown error';
|
|
addProvLog('error', 'Device rejected provisioning: ' + errorMsg);
|
|
if (errorMsg === 'missing_provision_key') throw new UserError('Firmware communication error. Please try again.');
|
|
if (errorMsg === 'nvs_write_failed') throw new UserError('Failed to save configuration to device. Please try again.');
|
|
throw new UserError('Provisioning failed: ' + errorMsg);
|
|
}
|
|
|
|
addProvLog('log', 'Provisioning acknowledged — MAC: ' + (response.mac || mac || '(unknown)'));
|
|
return response.mac || mac;
|
|
|
|
} finally {
|
|
try { reader.cancel(); } catch (_) {}
|
|
try { await readableClosed.catch(function () {}); } catch (_) {}
|
|
try { await port.close(); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
function showFormError(id, msg) {
|
|
var el = document.getElementById(id);
|
|
if (el) { el.style.display = 'block'; el.textContent = msg; }
|
|
}
|
|
|
|
function renderDetectNode(contentEl) {
|
|
var detectTitle = state.reproveMode
|
|
? 'Waiting for Node to Reconnect'
|
|
: 'Detecting Your Node';
|
|
var detectDesc = state.reproveMode
|
|
? 'Unplug and replug the ESP32-S3 — it will reconnect with its new token. This may take up to 30 seconds.'
|
|
: 'The ESP32-S3 is booting and connecting to your WiFi network. This may take up to 30 seconds.';
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
'<h2>' + detectTitle + '</h2>' +
|
|
'<p>' + detectDesc + '</p>' +
|
|
'<div class="wizard-center-msg">' +
|
|
'<div class="spinner"></div>' +
|
|
'<p id="detect-status">Waiting for node to appear...</p>' +
|
|
'<p id="detect-countdown" class="wizard-muted"></p>' +
|
|
'</div>' +
|
|
'<div id="detect-troubleshoot" style="display:none">' +
|
|
'<h3>Troubleshooting</h3>' +
|
|
'<ul class="wizard-list">' +
|
|
'<li>Make sure your WiFi network is <strong>2.4 GHz</strong> (ESP32-S3 does not support 5 GHz)</li>' +
|
|
'<li>Check that the SSID and password are correct</li>' +
|
|
'<li>Ensure the ESP32-S3 is within range of your WiFi router</li>' +
|
|
'<li>Your router may block device-to-device communication (AP isolation) — check router settings</li>' +
|
|
'<li>If using VLANs, ensure the ESP32-S3 and this computer are on the same VLAN</li>' +
|
|
'</ul>' +
|
|
'<div id="detect-captive" style="display:none" class="wizard-warn">' +
|
|
'<p>If the node cannot connect after 10 failed attempts, it enters <strong>captive portal mode</strong>. ' +
|
|
'Look for a WiFi network named <strong>Spaxel-Setup</strong> and connect to it to reconfigure.</p>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
hideNav();
|
|
|
|
var startTime = Date.now();
|
|
var timeoutMs = CONFIG.nodePollTimeout;
|
|
|
|
state.pollTimer = setInterval(function () {
|
|
var elapsed = Date.now() - startTime;
|
|
var remaining = Math.max(0, Math.ceil((timeoutMs - elapsed) / 1000));
|
|
|
|
document.getElementById('detect-countdown').textContent =
|
|
'Timeout in ' + remaining + 's';
|
|
|
|
if (elapsed >= timeoutMs) {
|
|
clearInterval(state.pollTimer);
|
|
state.pollTimer = null;
|
|
document.getElementById('detect-status').textContent = 'Node not found within timeout.';
|
|
document.getElementById('detect-status').className = 'wizard-error';
|
|
document.getElementById('detect-countdown').style.display = 'none';
|
|
document.getElementById('detect-troubleshoot').style.display = 'block';
|
|
document.getElementById('detect-captive').style.display = 'block';
|
|
|
|
renderNav(true, 'Retry Detection', function () {
|
|
goToStep(state.currentStepIndex);
|
|
});
|
|
return;
|
|
}
|
|
|
|
fetch(CONFIG.nodesEndpoint)
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (nodes) {
|
|
var currentMACs = (nodes || []).map(function (n) { return n.mac; });
|
|
var newMAC = null;
|
|
|
|
if (state.reproveMode && state.reproveMAC) {
|
|
// In reprove mode, only accept the specific target MAC.
|
|
if (currentMACs.indexOf(state.reproveMAC) !== -1) {
|
|
newMAC = state.reproveMAC;
|
|
}
|
|
} else {
|
|
for (var i = 0; i < currentMACs.length; i++) {
|
|
if (state.knownMACs.indexOf(currentMACs[i]) === -1) {
|
|
newMAC = currentMACs[i];
|
|
break;
|
|
}
|
|
}
|
|
// Also accept the first online node if no known MACs were recorded
|
|
if (!newMAC && state.knownMACs.length === 0 && currentMACs.length > 0) {
|
|
newMAC = currentMACs[0];
|
|
}
|
|
}
|
|
|
|
if (newMAC) {
|
|
clearInterval(state.pollTimer);
|
|
state.pollTimer = null;
|
|
state.nodeMAC = newMAC;
|
|
document.getElementById('detect-status').textContent =
|
|
'Found node: ' + newMAC;
|
|
document.getElementById('detect-status').className = 'wizard-success';
|
|
saveState();
|
|
setTimeout(function () { goToStep(state.currentStepIndex + 1); }, 1000);
|
|
}
|
|
})
|
|
.catch(function () { /* network error, will retry */ });
|
|
}, CONFIG.nodePollInterval);
|
|
|
|
return {
|
|
cleanup: function () {
|
|
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
|
|
}
|
|
};
|
|
}
|
|
|
|
function renderCalibrate(contentEl) {
|
|
state.calibratePhase = 'walk';
|
|
state.csiHistory = [];
|
|
state.calibrationLinks = [];
|
|
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
'<h2>Guided Calibration</h2>' +
|
|
'<div id="calibrate-instructions"></div>' +
|
|
'<canvas id="calibrate-canvas" width="480" height="120" style="width:100%;height:120px;border-radius:4px;margin:12px 0"></canvas>' +
|
|
'<div id="calibrate-status" class="wizard-muted"></div>' +
|
|
'</div>';
|
|
|
|
hideNav();
|
|
|
|
// Connect to dashboard WebSocket for live CSI data
|
|
connectCalibrationWS();
|
|
|
|
// Phase 1: Walk around
|
|
startCalibratePhase('walk');
|
|
|
|
return {
|
|
cleanup: function () {
|
|
if (state.calibrateTimer) { clearTimeout(state.calibrateTimer); state.calibrateTimer = null; }
|
|
if (state.ws) { state.ws.close(); state.ws = null; }
|
|
state.calibratePhase = 'idle';
|
|
}
|
|
};
|
|
}
|
|
|
|
function connectCalibrationWS() {
|
|
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
var url = protocol + '//' + window.location.host + '/ws/dashboard';
|
|
|
|
try {
|
|
state.ws = new WebSocket(url);
|
|
state.ws.binaryType = 'arraybuffer';
|
|
|
|
state.ws.onmessage = function (event) {
|
|
if (event.data instanceof ArrayBuffer && state.nodeMAC) {
|
|
var frame = parseCSIFrame(event.data);
|
|
if (frame && (frame.nodeMAC === state.nodeMAC || frame.peerMAC === state.nodeMAC)) {
|
|
pushCSISample(frame);
|
|
drawCalibrateWaveform();
|
|
// Track unique links for post-calibration card
|
|
var linkKey = frame.nodeMAC + ':' + frame.peerMAC;
|
|
if (state.calibrationLinks.indexOf(linkKey) === -1) {
|
|
state.calibrationLinks.push(linkKey);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
state.ws.onerror = function () { /* non-critical */ };
|
|
} catch (e) { /* WebSocket not available, non-critical */ }
|
|
}
|
|
|
|
// ============================================
|
|
// CSI Frame Parser (matches Go binary format)
|
|
// ============================================
|
|
function parseCSIFrame(buffer) {
|
|
var view = new DataView(buffer);
|
|
var bytes = new Uint8Array(buffer);
|
|
if (bytes.length < 24) return null;
|
|
|
|
var nodeMAC = formatMAC(bytes, 0);
|
|
var peerMAC = formatMAC(bytes, 6);
|
|
var nSub = bytes[23];
|
|
var channel = bytes[22];
|
|
|
|
if (channel === 0 || channel > 14) return null;
|
|
var expectedLen = 24 + nSub * 2;
|
|
if (bytes.length !== expectedLen) return null;
|
|
|
|
var sum = 0;
|
|
for (var i = 0; i < nSub; i++) {
|
|
var offset = 24 + i * 2;
|
|
var iVal = bytes[offset] > 127 ? bytes[offset] - 256 : bytes[offset];
|
|
var qVal = bytes[offset + 1] > 127 ? bytes[offset + 1] - 256 : bytes[offset + 1];
|
|
sum += Math.sqrt(iVal * iVal + qVal * qVal);
|
|
}
|
|
return {
|
|
nodeMAC: nodeMAC,
|
|
peerMAC: peerMAC,
|
|
meanAmplitude: nSub > 0 ? sum / nSub : 0,
|
|
rssi: view.getInt8(20),
|
|
};
|
|
}
|
|
|
|
function pushCSISample(frame) {
|
|
state.csiHistory.push({ t: Date.now(), amp: frame.meanAmplitude });
|
|
var cutoff = Date.now() - 10000;
|
|
while (state.csiHistory.length > 0 && state.csiHistory[0].t < cutoff) {
|
|
state.csiHistory.shift();
|
|
}
|
|
}
|
|
|
|
function drawCalibrateWaveform() {
|
|
var canvas = document.getElementById('calibrate-canvas');
|
|
if (!canvas) return;
|
|
var ctx = canvas.getContext('2d');
|
|
var w = canvas.width;
|
|
var h = canvas.height;
|
|
|
|
ctx.fillStyle = '#12122a';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
var data = state.csiHistory;
|
|
if (data.length < 2) {
|
|
ctx.fillStyle = '#444';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Waiting for CSI data...', w / 2, h / 2 + 4);
|
|
return;
|
|
}
|
|
|
|
var maxAmp = 1;
|
|
for (var i = 0; i < data.length; i++) {
|
|
if (data[i].amp > maxAmp) maxAmp = data[i].amp;
|
|
}
|
|
|
|
var xStep = w / Math.max(data.length - 1, 1);
|
|
var padTop = 8;
|
|
var padBottom = 8;
|
|
var plotH = h - padTop - padBottom;
|
|
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeStyle = '#4fc3f7';
|
|
ctx.beginPath();
|
|
for (var j = 0; j < data.length; j++) {
|
|
var x = j * xStep;
|
|
var y = padTop + plotH - (data[j].amp / maxAmp) * plotH;
|
|
if (j === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Fill area
|
|
ctx.lineTo((data.length - 1) * xStep, padTop + plotH);
|
|
ctx.lineTo(0, padTop + plotH);
|
|
ctx.closePath();
|
|
ctx.fillStyle = 'rgba(79,195,247,0.1)';
|
|
ctx.fill();
|
|
}
|
|
|
|
// ============================================
|
|
// Calibration Phases
|
|
// ============================================
|
|
function startCalibratePhase(phase) {
|
|
state.calibratePhase = phase;
|
|
var instructions = document.getElementById('calibrate-instructions');
|
|
var statusEl = document.getElementById('calibrate-status');
|
|
|
|
switch (phase) {
|
|
case 'walk':
|
|
instructions.innerHTML =
|
|
'<div class="calibrate-phase">' +
|
|
'<div class="calibrate-phase-number">1 of 3</div>' +
|
|
'<h3>Walk Around Your Space</h3>' +
|
|
'<p>Walk around the room for <strong>30 seconds</strong>. The waveform below should show activity.</p>' +
|
|
'</div>';
|
|
statusEl.textContent = 'If the waveform stays flat, try rotating the node or moving closer.';
|
|
runCalibrateCountdown(CONFIG.calibrateWalkDuration, function () {
|
|
if (state.calibratePhase !== 'walk') return;
|
|
// Check if we got any data
|
|
if (state.csiHistory.length < 5) {
|
|
statusEl.innerHTML =
|
|
'<span class="wizard-warn">Very little CSI data received. ' +
|
|
'The node connected but is not sensing yet. Check the antenna ' +
|
|
'orientation \u2014 the PCB antenna should face away from walls.</span>';
|
|
}
|
|
startCalibratePhase('still');
|
|
});
|
|
break;
|
|
|
|
case 'still':
|
|
instructions.innerHTML =
|
|
'<div class="calibrate-phase">' +
|
|
'<div class="calibrate-phase-number">2 of 3</div>' +
|
|
'<h3>Stand Still</h3>' +
|
|
'<p>Stand still at the far end of the room. The system will capture a baseline.</p>' +
|
|
'</div>';
|
|
statusEl.innerHTML = '<span id="still-countdown" style="color:#4fc3f7;font-weight:600"></span>';
|
|
runCalibrateCountdown(CONFIG.calibrateStillDuration, function () {
|
|
if (state.calibratePhase !== 'still') return;
|
|
statusEl.innerHTML = '<span class="wizard-success">✓ Baseline captured</span>';
|
|
startCalibratePhase('walk_through');
|
|
}, function (remaining) {
|
|
var el = document.getElementById('still-countdown');
|
|
if (el) el.textContent = remaining + 's remaining';
|
|
});
|
|
break;
|
|
|
|
case 'walk_through':
|
|
instructions.innerHTML =
|
|
'<div class="calibrate-phase">' +
|
|
'<div class="calibrate-phase-number">3 of 3</div>' +
|
|
'<h3>Walk Through the Centre</h3>' +
|
|
'<p>Walk through the centre of the room. The sensor should detect your movement.</p>' +
|
|
'</div>';
|
|
statusEl.textContent = 'The sensor can see you!';
|
|
runCalibrateCountdown(CONFIG.calibrateWalkThroughDuration, function () {
|
|
if (state.calibratePhase !== 'walk_through') return;
|
|
showPostCalibrationCard();
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
function runCalibrateCountdown(durationMs, onComplete, onTick) {
|
|
var startTime = Date.now();
|
|
function tick() {
|
|
var elapsed = Date.now() - startTime;
|
|
var remaining = Math.max(0, Math.ceil((durationMs - elapsed) / 1000));
|
|
|
|
if (onTick) onTick(remaining);
|
|
|
|
if (elapsed >= durationMs) {
|
|
onComplete();
|
|
return;
|
|
}
|
|
state.calibrateTimer = setTimeout(tick, 200);
|
|
}
|
|
tick();
|
|
}
|
|
|
|
// ============================================
|
|
// Post-Calibration Reinforcement Card
|
|
// ============================================
|
|
function showPostCalibrationCard() {
|
|
var instructions = document.getElementById('calibrate-instructions');
|
|
var statusEl = document.getElementById('calibrate-status');
|
|
if (!instructions || !statusEl) return;
|
|
|
|
var linkCount = state.calibrationLinks.length || 1;
|
|
var nodeLabel = state.nodeMAC || 'Node';
|
|
|
|
instructions.innerHTML =
|
|
'<div class="post-cal-card">' +
|
|
'<div class="wizard-icon-large wizard-success-icon">\u2713</div>' +
|
|
'<h3>You\'re All Set!</h3>' +
|
|
'<p class="post-cal-summary">' + escapeAttr(nodeLabel) + ' calibrated. ' +
|
|
linkCount + ' sensing link' + (linkCount !== 1 ? 's' : '') +
|
|
' active. Motion detection: Ready.</p>' +
|
|
'<p class="post-cal-expect">You\'ll see the CSI waveform react when someone walks through the room. ' +
|
|
'The system learns your space over the next few hours and becomes more accurate.</p>' +
|
|
'<div class="post-cal-actions">' +
|
|
'<button class="wizard-btn wizard-btn-secondary" id="post-cal-add">Add another node</button>' +
|
|
'<button class="wizard-btn wizard-btn-primary" id="post-cal-done">I\'m done for now</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
statusEl.innerHTML = '';
|
|
|
|
document.getElementById('post-cal-add').addEventListener('click', function () {
|
|
clearState();
|
|
closeWizard();
|
|
// Let the user start a new wizard from the dashboard
|
|
});
|
|
|
|
document.getElementById('post-cal-done').addEventListener('click', function () {
|
|
saveState();
|
|
goToStep(state.currentStepIndex + 1);
|
|
});
|
|
}
|
|
|
|
function renderPlacement(contentEl) {
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content">' +
|
|
'<h2>Node Placement</h2>' +
|
|
'<p>Your node is online and calibrated. For optimal coverage:</p>' +
|
|
'<ul class="wizard-list">' +
|
|
'<li>Place nodes at <strong>opposite corners</strong> of the room for best coverage</li>' +
|
|
'<li>Keep nodes at least <strong>2 meters</strong> apart</li>' +
|
|
'<li>Avoid placing nodes near <strong>metal objects</strong> or <strong>thick walls</strong></li>' +
|
|
'<li>Mount nodes at <strong>chest height</strong> (1.2-1.5m) for person detection</li>' +
|
|
'<li>Ensure nodes have a <strong>clear line of sight</strong> to each other</li>' +
|
|
'</ul>' +
|
|
'<p class="wizard-muted">You can add more nodes later by running this wizard again.</p>' +
|
|
'</div>';
|
|
|
|
renderNav(true, 'Finish Setup', function () {
|
|
saveState();
|
|
goToStep(state.currentStepIndex + 1);
|
|
});
|
|
|
|
return { cleanup: function () { } };
|
|
}
|
|
|
|
function renderComplete(contentEl) {
|
|
var nodeInfo = state.nodeMAC ?
|
|
'<p>Your node <strong>' + state.nodeMAC + '</strong> is now online.</p>' : '';
|
|
|
|
contentEl.innerHTML =
|
|
'<div class="wizard-step-content" style="text-align:center">' +
|
|
'<div class="wizard-icon-large wizard-success-icon">✓</div>' +
|
|
'<h2>Setup Complete!</h2>' +
|
|
nodeInfo +
|
|
'<p>You can now monitor your node and view live CSI data on the dashboard.</p>' +
|
|
'<div style="margin-top:24px">' +
|
|
'<button class="wizard-btn wizard-btn-primary" id="goto-dashboard">Go to Dashboard</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
hideNav();
|
|
|
|
document.getElementById('goto-dashboard').addEventListener('click', function () {
|
|
closeWizard();
|
|
});
|
|
|
|
return { cleanup: function () { } };
|
|
}
|
|
|
|
// ============================================
|
|
// Step Router
|
|
// ============================================
|
|
var renderers = {
|
|
browser_check: renderBrowserCheck,
|
|
connect_device: renderConnectDevice,
|
|
flash_firmware: renderFlashFirmware,
|
|
provision_wifi: renderProvisionWifi,
|
|
detect_node: renderDetectNode,
|
|
calibrate: renderCalibrate,
|
|
placement: renderPlacement,
|
|
complete: renderComplete,
|
|
};
|
|
|
|
var activeCleanup = null;
|
|
|
|
function goToStep(index) {
|
|
if (index < 0 || index >= STEPS.length) return;
|
|
|
|
// In reprove mode, skip calibrate and placement — node is already positioned.
|
|
if (state.reproveMode) {
|
|
var stepId = STEPS[index] ? STEPS[index].id : '';
|
|
if (stepId === 'calibrate' || stepId === 'placement') {
|
|
index = index > state.currentStepIndex ? index + 1 : index - 1;
|
|
if (index < 0 || index >= STEPS.length) return;
|
|
}
|
|
}
|
|
|
|
// Cleanup previous step
|
|
if (activeCleanup) {
|
|
activeCleanup.cleanup();
|
|
activeCleanup = null;
|
|
}
|
|
|
|
state.currentStepIndex = index;
|
|
saveState();
|
|
renderStepIndicator();
|
|
|
|
var contentEl = document.getElementById('wizard-content');
|
|
if (!contentEl) return;
|
|
contentEl.innerHTML = '';
|
|
|
|
var step = STEPS[index];
|
|
if (renderers[step.id]) {
|
|
activeCleanup = renderers[step.id](contentEl);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Wizard Container
|
|
// ============================================
|
|
function createWizardUI() {
|
|
var overlay = document.createElement('div');
|
|
overlay.id = 'wizard-overlay';
|
|
|
|
overlay.innerHTML =
|
|
'<div id="wizard-card">' +
|
|
'<div id="wizard-header">' +
|
|
'<h1>Spaxel Setup</h1>' +
|
|
'<div style="display:flex;align-items:center;gap:12px">' +
|
|
'<button id="wizard-restart-btn" title="Start over" style="background:none;border:none;' +
|
|
'color:#546e7a;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:4px;' +
|
|
'text-decoration:underline">Start Over</button>' +
|
|
'<button class="wizard-close" id="wizard-close-btn" title="Close">×</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div id="wizard-steps"></div>' +
|
|
'<div id="wizard-content"></div>' +
|
|
'<div id="wizard-nav"></div>' +
|
|
'</div>';
|
|
|
|
document.body.appendChild(overlay);
|
|
state.container = overlay;
|
|
|
|
document.getElementById('wizard-close-btn').addEventListener('click', closeWizard);
|
|
document.getElementById('wizard-restart-btn').addEventListener('click', function () {
|
|
clearState();
|
|
if (activeCleanup) { activeCleanup.cleanup(); activeCleanup = null; }
|
|
goToStep(0);
|
|
});
|
|
overlay.addEventListener('click', function (e) {
|
|
if (e.target === overlay) closeWizard();
|
|
});
|
|
}
|
|
|
|
function closeWizard() {
|
|
if (activeCleanup) {
|
|
activeCleanup.cleanup();
|
|
activeCleanup = null;
|
|
}
|
|
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
|
|
if (state.ws) { state.ws.close(); state.ws = null; }
|
|
|
|
if (state.container && state.container.parentNode) {
|
|
state.container.parentNode.removeChild(state.container);
|
|
}
|
|
state.container = null;
|
|
|
|
// Clear reprove mode flags on close.
|
|
state.reproveMode = false;
|
|
state.reproveMAC = null;
|
|
|
|
// Don't clear state — allow resume if user navigates back to /onboard
|
|
}
|
|
|
|
function startWizard() {
|
|
// Prevent duplicate instances
|
|
if (state.container) {
|
|
state.container.parentNode.removeChild(state.container);
|
|
}
|
|
|
|
createWizardUI();
|
|
|
|
// In reprove mode, always start fresh at the connect step.
|
|
if (state.reproveMode) {
|
|
clearState();
|
|
var connectIdx = STEPS.findIndex(function (s) { return s.id === 'connect_device'; });
|
|
goToStep(connectIdx >= 0 ? connectIdx : 0);
|
|
return;
|
|
}
|
|
|
|
var saved = loadState();
|
|
if (saved && typeof saved.currentStepIndex === 'number' && saved.currentStepIndex >= 0) {
|
|
state.currentStepIndex = saved.currentStepIndex;
|
|
state.nodeMAC = saved.nodeMAC || null;
|
|
state.knownMACs = saved.knownMACs || [];
|
|
state.wifiSSID = saved.wifiSSID || '';
|
|
state.wifiPass = saved.wifiPass || '';
|
|
state.mothershipHost = saved.mothershipHost || '';
|
|
state.mothershipPort = saved.mothershipPort || 8080;
|
|
state.mothershipIP = saved.mothershipIP || '';
|
|
// After a page reload the serial port reference is gone. If we were at the
|
|
// flash step or beyond, drop back to connect so the user can re-select their
|
|
// device rather than landing on a broken flash screen.
|
|
var flashStepIndex = STEPS.findIndex(function (s) { return s.id === 'flash_firmware'; });
|
|
var connectStepIndex = STEPS.findIndex(function (s) { return s.id === 'connect_device'; });
|
|
if (state.currentStepIndex >= flashStepIndex && !state.port) {
|
|
goToStep(connectStepIndex >= 0 ? connectStepIndex : 0);
|
|
} else {
|
|
goToStep(state.currentStepIndex);
|
|
}
|
|
} else {
|
|
goToStep(0);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Public API
|
|
// ============================================
|
|
window.SpaxelOnboard = {
|
|
start: startWizard,
|
|
close: closeWizard,
|
|
reprove: function (mac) {
|
|
state.reproveMode = true;
|
|
state.reproveMAC = mac || null;
|
|
startWizard();
|
|
},
|
|
};
|
|
|
|
// Expose internals for testing
|
|
window.SpaxelOnboard._state = state;
|
|
window.SpaxelOnboard._CONFIG = CONFIG;
|
|
window.SpaxelOnboard._STEPS = STEPS;
|
|
window.SpaxelOnboard._parseCSIFrame = parseCSIFrame;
|
|
window.SpaxelOnboard._provisionAndSend = provisionAndSend;
|
|
window.SpaxelOnboard._UserError = UserError;
|
|
window.SpaxelOnboard._isUserError = isUserError;
|
|
window.SpaxelOnboard._showPostCalibrationCard = showPostCalibrationCard;
|
|
|
|
// Auto-start if on /onboard path
|
|
if (window.location.pathname === '/onboard') {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', startWizard);
|
|
} else {
|
|
startWizard();
|
|
}
|
|
}
|
|
})();
|