Add WiFi provisioning observability and extend firmware window

- Show real-time status messages and a collapsible log panel during WiFi provisioning
- Thread addProvLog/setProvStatus callbacks through provisionAndSend and sendPayloadOverSerial
- Log every stage: mothership fetch, payload assembly, port open retries, serial send/response
- All log lines also go to browser console.log/warn/error
- Firmware: extend provisioning window from 10s to 120s for fresh boards (15s for re-provisioning)
- Firmware: include MAC address in SPAXEL READY message for display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-16 14:35:39 -04:00
parent aa61e7a579
commit cc84a6eea8
3 changed files with 90 additions and 21 deletions

View file

@ -606,14 +606,41 @@
'</div>' +
'</details>' +
'<div id="provision-error" class="wizard-error" style="display:none"></div>' +
'<p id="provision-status" style="display:none;margin:8px 0;font-size:12px;color:#80cbc4"></p>' +
'<button type="submit" class="wizard-btn wizard-btn-primary">Send Configuration</button>' +
'</form>' +
'<details id="provision-log-details" style="margin-top:12px;font-size:12px;display:none">' +
' <summary style="cursor:pointer;color:#546e7a">Show log</summary>' +
' <div id="provision-log-body" style="background:#0a0e13;border:1px solid #263238;border-radius:4px;' +
'padding:8px;margin-top:4px;max-height:120px;overflow-y:auto;font-family:monospace"></div>' +
'</details>' +
'</div>';
renderNav(true, '', function () { }, true);
// Hide the default Next button since we use the form submit
hideNav();
function setProvStatus(msg) {
var el = document.getElementById('provision-status');
if (el) { el.style.display = msg ? 'block' : 'none'; el.textContent = msg; }
}
function addProvLog(level, msg) {
var ts = new Date().toISOString().slice(11, 23);
var logEl = document.getElementById('provision-log-body');
var detailsEl = document.getElementById('provision-log-details');
if (logEl) {
if (detailsEl) detailsEl.style.display = 'block';
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;
}
console[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log']('[provision] ' + msg);
}
document.getElementById('wifi-form').addEventListener('submit', function (e) {
e.preventDefault();
var ssid = document.getElementById('wifi-ssid').value.trim();
@ -634,10 +661,13 @@
var btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
btn.textContent = 'Sending...';
document.getElementById('provision-error').style.display = 'none';
setProvStatus('Contacting mothership...');
addProvLog('log', 'Submitting: ssid=' + ssid + ' msHost=' + (msHost || '(mDNS)') + ' msPort=' + msPort);
provisionAndSend(ssid, pass, msHost, msPort)
provisionAndSend(ssid, pass, msHost, msPort, addProvLog, setProvStatus)
.then(function () {
// Fetch current known nodes before provisioning
// Fetch current known nodes before advancing
return fetch(CONFIG.nodesEndpoint)
.then(function (r) { return r.json(); })
.then(function (nodes) {
@ -646,13 +676,16 @@
.catch(function () { /* ignore */ });
})
.then(function () {
setProvStatus('');
saveState();
goToStep(state.currentStepIndex + 1);
})
.catch(function (err) {
var msg = isUserError(err) ? err.message :
'Could not send configuration. Make sure the device is connected via USB and try again.';
addProvLog('error', 'Failed: ' + (err.message || String(err)));
showFormError('provision-error', msg);
setProvStatus('');
btn.disabled = false;
btn.textContent = 'Send Configuration';
});
@ -661,7 +694,13 @@
return { cleanup: function () { } };
}
function provisionAndSend(ssid, pass, msHost, msPort) {
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',
@ -669,16 +708,21 @@
body: JSON.stringify({ wifi_ssid: ssid, wifi_pass: pass }),
})
.then(function (r) {
if (!r.ok) throw new Error('provisioning server error');
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;
return sendPayloadOverSerial(payload);
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 () {
.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,
@ -694,20 +738,27 @@
ms_port: msPort,
debug: false,
};
return sendPayloadOverSerial(payload);
addProvLog('log', 'Fallback payload — node_id=' + payload.node_id);
return sendPayloadOverSerial(payload, addProvLog, setProvStatus);
});
}
async function sendPayloadOverSerial(payload) {
async function sendPayloadOverSerial(payload, addProvLog, setProvStatus) {
addProvLog = addProvLog || function () {};
setProvStatus = setProvStatus || function () {};
// Firmware expects {"provision": {...}} format
var wrappedPayload = { provision: payload };
// Prefer the port the user explicitly selected in the Connect step. Fall back to
// whatever the browser has previously authorized if state.port was somehow lost.
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). Open it with retries
// to handle the brief gap while the device reboots and re-enumerates.
@ -716,36 +767,46 @@
try {
await port.open({ baudRate: CONFIG.serialBaudRate });
opened = true;
addProvLog('log', 'Port opened on attempt ' + (attempt + 1));
break;
} catch (e) {
// Already open → proceed
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));
// Device not ready yet — wait and retry
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.'
);
}
addProvLog('log', 'Sending JSON payload: ' + JSON.stringify(wrappedPayload).substring(0, 120) + '...');
setProvStatus('Waiting for device acknowledgment...');
var response = await sendSerialJSONAndWaitForResponse(port, wrappedPayload, 15000);
addProvLog('log', 'Serial response: ' + (response ? JSON.stringify(response) : '(none — timeout)'));
try { await port.close(); } catch (_) {}
if (!response) {
throw new UserError(
'No response from device. Make sure the board finished booting and try again.'
'No response from device. Make sure the board finished booting and try again. ' +
'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.');
}
@ -754,6 +815,7 @@
}
throw new UserError('Provisioning failed: ' + errorMsg);
}
addProvLog('log', 'Provisioning acknowledged by device — MAC: ' + (response.mac || '(unknown)'));
return response.mac;
}

View file

@ -11,11 +11,12 @@
static const char *TAG = "provision";
#define PROVISION_UART UART_NUM_0
#define PROVISION_BAUD_RATE 115200
#define PROVISION_WINDOW_MS 10000
#define UART_RX_BUF_SIZE 1024
#define MAX_LINE_LEN 768
#define PROVISION_UART UART_NUM_0
#define PROVISION_BAUD_RATE 115200
#define PROVISION_WINDOW_MS_FRESH 120000 // 2 min for unprovisioned boards
#define PROVISION_WINDOW_MS_REPROV 15000 // 15 s for already-provisioned boards
#define UART_RX_BUF_SIZE 1024
#define MAX_LINE_LEN 768
void provision_listen_window(void) {
uart_config_t uart_cfg = {
@ -39,13 +40,18 @@ void provision_listen_window(void) {
char mac_str[18];
mac_to_str(g_state.mac, mac_str, sizeof(mac_str));
// Signal that firmware is ready for provisioning
const char *ready = "SPAXEL READY\n";
uart_write_bytes(PROVISION_UART, ready, strlen(ready));
uint32_t window_ms = g_state.provisioned
? PROVISION_WINDOW_MS_REPROV
: PROVISION_WINDOW_MS_FRESH;
ESP_LOGI(TAG, "Provisioning window open for %d ms (MAC: %s)", PROVISION_WINDOW_MS, mac_str);
// Signal that firmware is ready for provisioning (includes MAC for display)
char ready_msg[64];
snprintf(ready_msg, sizeof(ready_msg), "SPAXEL READY %s\n", mac_str);
uart_write_bytes(PROVISION_UART, ready_msg, strlen(ready_msg));
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(PROVISION_WINDOW_MS);
ESP_LOGI(TAG, "Provisioning window open for %d ms (MAC: %s)", window_ms, mac_str);
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(window_ms);
char line[MAX_LINE_LEN];
int line_pos = 0;

View file

@ -3,8 +3,9 @@
#include "esp_err.h"
#include "cJSON.h"
// provision_listen_window opens a 10-second serial provisioning window.
// Prints "SPAXEL READY\n" and reads {"provision": {...}}\n from UART0.
// provision_listen_window opens a serial provisioning window.
// Unprovisioned boards wait 120 s; already-provisioned boards wait 15 s.
// Prints "SPAXEL READY <MAC>\n" and reads {"provision": {...}}\n from UART0.
// Responds with {"ok": true, "mac": "..."}\n on success.
// Safe to call even if no host is connected — times out cleanly.
void provision_listen_window(void);