diff --git a/dashboard/js/app.js b/dashboard/js/app.js
index eec7068..65fdf99 100644
--- a/dashboard/js/app.js
+++ b/dashboard/js/app.js
@@ -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 = '
';
+ } else if (p.state === 'downloading' || p.state === 'rebooting') {
+ otaBadge = '
';
+ } else if (p.state === 'verified') {
+ otaBadge = '
';
+ }
+ }
+ }
+
+ // Firmware version display (shortened)
+ const fwDisplay = node.firmware ? '
${mac}
+ ${fwDisplay}
+ ${rollbackBadge}
+ ${otaBadge}
${statusLabel}
@@ -530,6 +554,11 @@
});
}
+ function escapeHtml(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g, '&').replace(//g, '>');
+ }
+
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
};
})();
diff --git a/dashboard/js/onboard.js b/dashboard/js/onboard.js
index 70face4..d83c2cb 100644
--- a/dashboard/js/onboard.js
+++ b/dashboard/js/onboard.js
@@ -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;
+ });
});
}
diff --git a/dashboard/js/onboard.test.setup.js b/dashboard/js/onboard.test.setup.js
index e9e8f4f..2792f30 100644
--- a/dashboard/js/onboard.test.setup.js
+++ b/dashboard/js/onboard.test.setup.js
@@ -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 {};
diff --git a/dashboard/js/ota.js b/dashboard/js/ota.js
new file mode 100644
index 0000000..49f3e0b
--- /dev/null
+++ b/dashboard/js/ota.js
@@ -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 = `
+
+
+ Firmware Updates
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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 = '
Failed to load
';
+ }
+ }
+
+ 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 = '
No firmware available. Upload via /api/firmware/upload
';
+ return;
+ }
+
+ let html = '';
+ state.firmwareList.forEach(function(fw) {
+ const latestBadge = fw.is_latest
+ ? '
LATEST'
+ : '';
+ const sizeKB = Math.round(fw.size_bytes / 1024);
+
+ html += `
+
+
+
+ ${escapeHtml(fw.filename)}
+ ${latestBadge}
+
+ ${sizeKB} KB
+
+
+ SHA256: ${fw.sha256.substring(0, 12)}...
+
+
+ `;
+ });
+ firmwareList.innerHTML = html;
+ }
+
+ function renderProgress() {
+ const entries = Object.entries(state.progress);
+
+ if (entries.length === 0) {
+ progressList.innerHTML = '
No updates in progress
';
+ 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'
+ ? '
ROLLBACK'
+ : '';
+
+ html += `
+
+
+ ${mac}
+
+ ${stateInfo.label}
+ ${rollbackBadge}
+
+
+ ${progressBar}
+ ${p.error ? `
${escapeHtml(p.error)}
` : ''}
+ ${p.expected_version ? `
Target: ${escapeHtml(p.expected_version)}
` : ''}
+
+ `;
+ });
+ 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 `
+
+ `;
+ }
+
+ 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, '&').replace(//g, '>');
+ }
+
+ // ============================================
+ // 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();
+ }
+})();