feat(dashboard): complete Phase 4 onboarding & OTA system

Interactive onboarding wizard:
- 8-step Web Serial-based provisioning flow
- Firmware flashing via esp-web-install-button (CDN)
- Live CSI waveform feedback during guided calibration
- Server-side provisioning with client-side fallback
- Serial JSON response handling with error mapping
- Post-calibration reinforcement card with link count

OTA firmware management:
- Firmware list with SHA-256 hashes and size display
- Per-node progress tracking (idle/pending/downloading/rebooting/verified/failed/rollback)
- Rolling update orchestration via REST API
- Status bar button with state indicators (normal/in-progress/has-update)
- Node list badges for OTA status and rollback warnings

Guided troubleshooting:
- First-time feature tooltips with 8s auto-dismiss
- Sequential tooltip tour triggered on first node connection
- Node offline cards with step-by-step recovery instructions
- Factory reset instructions modal
- Client-side link health check (60s no-frame threshold)
- Captive portal recovery documentation

Exit criteria: New ESP32-S3 from unboxed to streaming CSI in under 5 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-28 21:21:55 -04:00
parent f76ab62698
commit 90e230f9d9
5 changed files with 582 additions and 4 deletions

View file

@ -802,6 +802,79 @@
background: rgba(244, 67, 54, 0.3);
}
/* Node firmware display */
.node-fw {
font-size: 10px;
color: #666;
margin-left: 6px;
font-family: monospace;
}
/* OTA rollback badge */
.node-rollback-badge {
background: rgba(244, 67, 54, 0.35);
color: #ef5350;
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
margin-left: 6px;
animation: rollback-pulse 1.5s ease-in-out infinite;
}
@keyframes rollback-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* OTA in-progress badge */
.node-ota-badge {
background: rgba(255, 167, 38, 0.35);
color: #ffa726;
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
margin-left: 6px;
}
/* OTA verified badge */
.node-verified-badge {
background: rgba(76, 175, 80, 0.35);
color: #66bb6a;
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
margin-left: 6px;
}
/* OTA panel button in status bar */
#ota-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #888;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
#ota-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #ccc;
}
#ota-btn.has-update {
background: rgba(76, 175, 80, 0.2);
border-color: rgba(76, 175, 80, 0.4);
color: #66bb6a;
}
#ota-btn.in-progress {
background: rgba(255, 167, 38, 0.2);
border-color: rgba(255, 167, 38, 0.4);
color: #ffa726;
}
/* Room editor panel */
#room-editor-panel {
position: fixed;
@ -1024,6 +1097,8 @@
<script type="module" src="https://espressif.github.io/esp-web-tools/dist/web/install-button.js"></script>
<!-- Onboarding wizard -->
<script src="js/onboard.js"></script>
<!-- OTA firmware management -->
<script src="js/ota.js"></script>
<!-- Room editor panel -->
<div id="room-editor-panel">

View file

@ -511,9 +511,33 @@
const isOnline = isVirtual || Date.now() - node.lastSeen < 30000;
const statusClass = isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline');
const statusLabel = isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline');
// Check for OTA rollback state
let rollbackBadge = '';
let otaBadge = '';
if (window.SpaxelOTA) {
const otaProgress = SpaxelOTA.getProgress();
if (otaProgress && otaProgress[mac]) {
const p = otaProgress[mac];
if (p.state === 'rollback') {
rollbackBadge = '<span class="node-rollback-badge">ROLLBACK</span>';
} else if (p.state === 'downloading' || p.state === 'rebooting') {
otaBadge = '<span class="node-ota-badge">OTA ' + (p.progress_pct || 0) + '%</span>';
} else if (p.state === 'verified') {
otaBadge = '<span class="node-verified-badge">UPDATED</span>';
}
}
}
// Firmware version display (shortened)
const fwDisplay = node.firmware ? '<span class="node-fw">' + escapeHtml(node.firmware) + '</span>' : '';
html += `
<div class="node-item" data-mac="${mac}">
<span class="node-mac">${mac}</span>
${fwDisplay}
${rollbackBadge}
${otaBadge}
<span class="node-status ${statusClass}">
${statusLabel}
</span>
@ -530,6 +554,11 @@
});
}
function escapeHtml(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function updatePresenceIndicator() {
let anyMotion = false;
state.links.forEach(link => {
@ -980,5 +1009,8 @@
// ============================================
window.SpaxelApp = {
getLinks: function () { return state.links; },
getNodes: function () { return state.nodes; },
refreshNodeList: updateNodeList,
refreshLinkList: updateLinkList
};
})();

View file

@ -133,6 +133,65 @@
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 */ }
}
@ -489,6 +548,8 @@
}
function sendPayloadOverSerial(payload) {
// Firmware expects {"provision": {...}} format
var wrappedPayload = { provision: payload };
return getAuthorizedPort()
.then(function (port) {
if (!port) throw new UserError(
@ -505,10 +566,26 @@
});
})
.then(function (port) {
return sendSerialJSON(port, payload).then(function () { return port; });
})
.then(function (port) {
return closePort(port);
return sendSerialJSONAndWaitForResponse(port, wrappedPayload, 15000)
.then(function (response) {
if (!response) {
throw new UserError(
'No response from device. Please ensure the ESP32-S3 is connected and try again.'
);
}
if (response.ok === false) {
var errorMsg = response.error || 'Unknown error';
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);
}
// Success - return the MAC address
return response.mac;
});
});
}

View file

@ -23,6 +23,24 @@ global.TextEncoderStream = class TextEncoderStream {
global.__getLastEncodedData = function () { return _lastEncodedData; };
global.__clearLastEncodedData = function () { _lastEncodedData = ''; };
// Mock TextDecoderStream (not available in jsdom)
var _lastDecodedChunk = '{"ok":true,"mac":"AA:BB:CC:DD:EE:FF"}\n';
global.TextDecoderStream = class TextDecoderStream {
constructor() {
this.readable = {
getReader: jest.fn().mockReturnValue({
read: jest.fn().mockResolvedValue({ done: false, value: _lastDecodedChunk }),
cancel: jest.fn().mockResolvedValue(undefined),
}),
};
this.writable = {
pipeTo: jest.fn().mockResolvedValue(undefined),
};
}
};
global.__setLastDecodedChunk = function (chunk) { _lastDecodedChunk = chunk; };
global.__getLastDecodedChunk = function () { return _lastDecodedChunk; };
// Mock ReadableStream/WritableStream (not available in jsdom)
global.ReadableStream = class ReadableStream {};
global.WritableStream = class WritableStream {};

376
dashboard/js/ota.js Normal file
View file

@ -0,0 +1,376 @@
/**
* Spaxel Dashboard - OTA Firmware Management
*
* Provides UI for firmware updates: list available versions,
* trigger rolling updates, display progress, and show rollback warnings.
*/
(function() {
'use strict';
// State
const state = {
firmwareList: [],
progress: {},
pollInterval: null,
otaInProgress: false
};
// ============================================
// DOM Elements
// ============================================
let panel, firmwareList, progressList, updateAllBtn, closeBtn;
// ============================================
// Initialization
// ============================================
function init() {
createPanel();
createTriggerButton();
startPolling();
}
function createPanel() {
// Create OTA panel (hidden by default)
panel = document.createElement('div');
panel.id = 'ota-panel';
panel.style.cssText = `
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
width: 400px;
max-height: 60vh;
background: rgba(0, 0, 0, 0.9);
border-radius: 8px;
padding: 16px;
z-index: 200;
display: none;
overflow-y: auto;
`;
panel.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="font-size: 14px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin: 0;">
Firmware Updates
</h3>
<button id="ota-close-btn" style="background: none; border: none; color: #888; font-size: 18px; cursor: pointer; padding: 0 4px;">&times;</button>
</div>
<div id="ota-firmware-section" style="margin-bottom: 16px;">
<div style="font-size: 12px; color: #666; margin-bottom: 6px;">Available Firmware</div>
<div id="ota-firmware-list" style="max-height: 120px; overflow-y: auto;"></div>
</div>
<div id="ota-action-section" style="margin-bottom: 16px;">
<button id="ota-update-all-btn" style="
width: 100%;
padding: 10px;
background: #4fc3f7;
color: #1a1a2e;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
">Update All Nodes</button>
</div>
<div id="ota-progress-section">
<div style="font-size: 12px; color: #666; margin-bottom: 6px;">Update Progress</div>
<div id="ota-progress-list"></div>
</div>
`;
document.body.appendChild(panel);
// Get references
firmwareList = document.getElementById('ota-firmware-list');
progressList = document.getElementById('ota-progress-list');
updateAllBtn = document.getElementById('ota-update-all-btn');
closeBtn = document.getElementById('ota-close-btn');
// Event handlers
closeBtn.addEventListener('click', hide);
updateAllBtn.addEventListener('click', triggerUpdateAll);
}
function createTriggerButton() {
// Add OTA button to status bar
const statusBar = document.getElementById('status-bar');
if (!statusBar) return;
const btn = document.createElement('button');
btn.id = 'ota-btn';
btn.textContent = 'OTA';
btn.style.cssText = `
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #888;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
`;
btn.addEventListener('click', toggle);
// Insert before FPS counter
const fpsItem = statusBar.querySelector('.status-item:last-child');
if (fpsItem) {
statusBar.insertBefore(btn, fpsItem);
} else {
statusBar.appendChild(btn);
}
}
// ============================================
// Panel Visibility
// ============================================
function toggle() {
if (panel.style.display === 'none') {
show();
} else {
hide();
}
}
function show() {
panel.style.display = 'block';
fetchFirmwareList();
fetchProgress();
}
function hide() {
panel.style.display = 'none';
}
// ============================================
// API Calls
// ============================================
async function fetchFirmwareList() {
try {
const resp = await fetch('/api/firmware');
if (!resp.ok) throw new Error('Failed to fetch firmware list');
state.firmwareList = await resp.json();
renderFirmwareList();
} catch (e) {
console.error('[OTA] Failed to fetch firmware:', e);
firmwareList.innerHTML = '<div style="color: #666; font-size: 12px;">Failed to load</div>';
}
}
async function fetchProgress() {
try {
const resp = await fetch('/api/firmware/progress');
if (!resp.ok) return;
state.progress = await resp.json();
renderProgress();
updateButtonState();
} catch (e) {
console.error('[OTA] Failed to fetch progress:', e);
}
}
async function triggerUpdateAll() {
if (state.otaInProgress) return;
updateAllBtn.disabled = true;
updateAllBtn.textContent = 'Starting...';
try {
const resp = await fetch('/api/firmware/ota-all', { method: 'POST' });
if (!resp.ok) throw new Error('Failed to start OTA');
state.otaInProgress = true;
updateAllBtn.textContent = 'Update in Progress...';
updateAllBtn.style.background = '#ffa726';
} catch (e) {
console.error('[OTA] Failed to trigger update:', e);
updateAllBtn.disabled = false;
updateAllBtn.textContent = 'Update All Nodes';
alert('Failed to start firmware update: ' + e.message);
}
}
// ============================================
// Rendering
// ============================================
function renderFirmwareList() {
if (!state.firmwareList || state.firmwareList.length === 0) {
firmwareList.innerHTML = '<div style="color: #666; font-size: 12px;">No firmware available. Upload via /api/firmware/upload</div>';
return;
}
let html = '';
state.firmwareList.forEach(function(fw) {
const latestBadge = fw.is_latest
? '<span style="background: rgba(76, 175, 80, 0.3); color: #81c784; font-size: 10px; padding: 1px 5px; border-radius: 3px; margin-left: 6px;">LATEST</span>'
: '';
const sizeKB = Math.round(fw.size_bytes / 1024);
html += `
<div style="padding: 6px 8px; margin-bottom: 4px; background: rgba(255,255,255,0.05); border-radius: 4px; font-size: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #ccc;">
${escapeHtml(fw.filename)}
${latestBadge}
</span>
<span style="color: #666; font-size: 11px;">${sizeKB} KB</span>
</div>
<div style="color: #555; font-size: 10px; margin-top: 2px; font-family: monospace;">
SHA256: ${fw.sha256.substring(0, 12)}...
</div>
</div>
`;
});
firmwareList.innerHTML = html;
}
function renderProgress() {
const entries = Object.entries(state.progress);
if (entries.length === 0) {
progressList.innerHTML = '<div style="color: #666; font-size: 12px;">No updates in progress</div>';
return;
}
let html = '';
entries.forEach(function([mac, p]) {
const stateInfo = getStateInfo(p.state);
const progressBar = renderProgressBar(p.progress_pct, stateInfo.color);
const rollbackBadge = p.state === 'rollback'
? '<span style="background: rgba(244, 67, 54, 0.4); color: #ef5350; font-size: 10px; padding: 2px 6px; border-radius: 3px; margin-left: 6px;">ROLLBACK</span>'
: '';
html += `
<div style="padding: 8px; margin-bottom: 6px; background: rgba(255,255,255,0.05); border-radius: 4px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-family: monospace; font-size: 12px; color: #4fc3f7;">${mac}</span>
<span style="color: ${stateInfo.color}; font-size: 11px; font-weight: 500;">
${stateInfo.label}
${rollbackBadge}
</span>
</div>
${progressBar}
${p.error ? `<div style="color: #ef5350; font-size: 11px; margin-top: 4px;">${escapeHtml(p.error)}</div>` : ''}
${p.expected_version ? `<div style="color: #666; font-size: 10px; margin-top: 2px;">Target: ${escapeHtml(p.expected_version)}</div>` : ''}
</div>
`;
});
progressList.innerHTML = html;
// Check for any active updates
const hasActive = entries.some(function([_, p]) {
return ['pending', 'downloading', 'rebooting'].includes(p.state);
});
if (!hasActive && state.otaInProgress) {
state.otaInProgress = false;
updateButtonState();
}
// Update status bar button state
updateStatusBarButton();
// Trigger node list refresh if app.js is available
if (window.SpaxelApp && typeof SpaxelApp.refreshNodeList === 'function') {
SpaxelApp.refreshNodeList();
}
}
function updateStatusBarButton() {
const btn = document.getElementById('ota-btn');
if (!btn) return;
const entries = Object.entries(state.progress);
const hasRollback = entries.some(function([_, p]) { return p.state === 'rollback'; });
const hasActive = entries.some(function([_, p]) {
return ['pending', 'downloading', 'rebooting'].includes(p.state);
});
btn.classList.remove('has-update', 'in-progress');
if (hasRollback) {
btn.classList.add('has-update');
btn.textContent = 'OTA!';
} else if (hasActive) {
btn.classList.add('in-progress');
btn.textContent = 'OTA...';
} else {
btn.textContent = 'OTA';
}
}
function renderProgressBar(pct, color) {
const pctVal = pct || 0;
return `
<div style="width: 100%; height: 4px; background: #333; border-radius: 2px; overflow: hidden;">
<div style="width: ${pctVal}%; height: 100%; background: ${color}; transition: width 0.3s;"></div>
</div>
`;
}
function getStateInfo(s) {
switch (s) {
case 'idle': return { label: 'Idle', color: '#888' };
case 'pending': return { label: 'Pending', color: '#4fc3f7' };
case 'downloading': return { label: 'Downloading', color: '#29b6f6' };
case 'rebooting': return { label: 'Rebooting', color: '#ffa726' };
case 'verified': return { label: 'Verified', color: '#66bb6a' };
case 'failed': return { label: 'Failed', color: '#ef5350' };
case 'rollback': return { label: 'Rollback', color: '#ef5350' };
default: return { label: s || 'Unknown', color: '#888' };
}
}
function updateButtonState() {
if (state.otaInProgress) {
updateAllBtn.disabled = true;
updateAllBtn.textContent = 'Update in Progress...';
updateAllBtn.style.background = '#ffa726';
} else {
updateAllBtn.disabled = false;
updateAllBtn.textContent = 'Update All Nodes';
updateAllBtn.style.background = '#4fc3f7';
}
}
// ============================================
// Polling
// ============================================
function startPolling() {
if (state.pollInterval) return;
state.pollInterval = setInterval(function() {
if (panel.style.display !== 'none' || state.otaInProgress) {
fetchProgress();
}
}, 2000);
}
// ============================================
// Utilities
// ============================================
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ============================================
// Public API
// ============================================
window.SpaxelOTA = {
init: init,
show: show,
hide: hide,
toggle: toggle,
getProgress: function() { return state.progress; },
isInProgress: function() { return state.otaInProgress; }
};
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();