/**
* 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,
};
// ============================================
// 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, '>');
}
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 += '
' + (i + 1) + '
';
if (i < STEPS.length - 1) {
var lineCls = 'wizard-step-line';
if (i < state.currentStepIndex) lineCls += ' completed';
html += '';
}
}
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 += '';
}
html += '';
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 =
'
';
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 =
'
' +
'
Configure WiFi
' +
'
Enter your WiFi credentials. These will be flashed to the device in the next step.
' +
'' +
'
';
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 " 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) {
contentEl.innerHTML =
'
' +
'
Detecting Your Node
' +
'
The ESP32-S3 is booting and connecting to your WiFi network. This may take up to 30 seconds.
' +
'
' +
'' +
'
Waiting for node to appear...
' +
'' +
'
' +
'
' +
'
Troubleshooting
' +
'
' +
'
Make sure your WiFi network is 2.4 GHz (ESP32-S3 does not support 5 GHz)
' +
'
Check that the SSID and password are correct
' +
'
Ensure the ESP32-S3 is within range of your WiFi router
' +
'
Your router may block device-to-device communication (AP isolation) — check router settings
' +
'
If using VLANs, ensure the ESP32-S3 and this computer are on the same VLAN
' +
'
' +
'
' +
'
If the node cannot connect after 10 failed attempts, it enters captive portal mode. ' +
'Look for a WiFi network named Spaxel-Setup and connect to it to reconfigure.
' +
'
' +
'
' +
'
';
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;
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 =
'
' +
'
Guided Calibration
' +
'' +
'' +
'' +
'
';
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 =
'
' +
'
1 of 3
' +
'
Walk Around Your Space
' +
'
Walk around the room for 30 seconds. The waveform below should show activity.
' +
'
';
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 =
'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.';
}
startCalibratePhase('still');
});
break;
case 'still':
instructions.innerHTML =
'
' +
'
2 of 3
' +
'
Stand Still
' +
'
Stand still at the far end of the room. The system will capture a baseline.
' +
'
';
statusEl.innerHTML = '';
runCalibrateCountdown(CONFIG.calibrateStillDuration, function () {
if (state.calibratePhase !== 'still') return;
statusEl.innerHTML = '✓ Baseline captured';
startCalibratePhase('walk_through');
}, function (remaining) {
var el = document.getElementById('still-countdown');
if (el) el.textContent = remaining + 's remaining';
});
break;
case 'walk_through':
instructions.innerHTML =
'
' +
'
3 of 3
' +
'
Walk Through the Centre
' +
'
Walk through the centre of the room. The sensor should detect your movement.
' +
'
';
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 =
'
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.
' +
'
' +
'' +
'' +
'
' +
'
';
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 =
'
' +
'
Node Placement
' +
'
Your node is online and calibrated. For optimal coverage:
' +
'
' +
'
Place nodes at opposite corners of the room for best coverage
' +
'
Keep nodes at least 2 meters apart
' +
'
Avoid placing nodes near metal objects or thick walls
' +
'
Mount nodes at chest height (1.2-1.5m) for person detection
' +
'
Ensure nodes have a clear line of sight to each other
' +
'
' +
'
You can add more nodes later by running this wizard again.
' +
'
';
renderNav(true, 'Finish Setup', function () {
saveState();
goToStep(state.currentStepIndex + 1);
});
return { cleanup: function () { } };
}
function renderComplete(contentEl) {
var nodeInfo = state.nodeMAC ?
'
Your node ' + state.nodeMAC + ' is now online.
' : '';
contentEl.innerHTML =
'
' +
'
✓
' +
'
Setup Complete!
' +
nodeInfo +
'
You can now monitor your node and view live CSI data on the dashboard.
' +
'
' +
'' +
'
' +
'
';
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;
// 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 =
'
' +
'
' +
'
Spaxel Setup
' +
'
' +
'' +
'' +
'
' +
'
' +
'' +
'' +
'' +
'
';
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;
// 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();
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,
};
// 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();
}
}
})();