diff --git a/index.html b/index.html index 2f07108..92153da 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - + + + diff --git a/public/firmware/ESP32_GENERIC_S3-20251209-v1.27.0.bin b/public/firmware/ESP32_GENERIC_S3-20251209-v1.27.0.bin new file mode 100644 index 0000000..0124e92 Binary files /dev/null and b/public/firmware/ESP32_GENERIC_S3-20251209-v1.27.0.bin differ diff --git a/src/main.js b/src/main.js index fd4f76f..40e7442 100644 --- a/src/main.js +++ b/src/main.js @@ -91,7 +91,6 @@ const terminalInput = document.getElementById('terminal-input'); function setConnectedUI(connected) { btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect'; - btnFlash.disabled = !connected; btnRun.disabled = !connected; btnStop.disabled = !connected; btnSave.disabled = !connected; @@ -122,17 +121,53 @@ btnConnect.addEventListener('click', async () => { } }); +const flashOverlay = document.getElementById('flash-overlay'); +const flashLog = document.getElementById('flash-log'); +const flashFill = document.getElementById('flash-progress-fill'); +const flashPctText = document.getElementById('flash-progress-text'); +const flashCloseBtn = document.getElementById('flash-close'); + +function showFlashOverlay() { + flashLog.textContent = ''; + flashFill.style.width = '0%'; + flashPctText.textContent = '0%'; + flashCloseBtn.classList.add('hidden'); + flashOverlay.classList.remove('hidden'); +} + +function appendFlashLog(msg) { + flashLog.textContent += msg; + flashLog.scrollTop = flashLog.scrollHeight; +} + +function setFlashProgress(pct) { + flashFill.style.width = pct + '%'; + flashPctText.textContent = pct + '%'; +} + +flashCloseBtn.addEventListener('click', () => { + flashOverlay.classList.add('hidden'); +}); + btnFlash.addEventListener('click', async () => { - try { - clearTerminal(); - appendToTerminal('Starting firmware flash...\n'); + if (isConnected()) { await disconnect(); - const port = await navigator.serial.requestPort(); - await flashFirmware(port, (msg) => appendToTerminal(msg + '\n')); - appendToTerminal('Flash complete! Reconnect to use the device.\n'); setConnectedUI(false); + } + + showFlashOverlay(); + + try { + await flashFirmware( + (msg) => appendFlashLog(msg), + (pct) => setFlashProgress(pct), + ); + setFlashProgress(100); + appendFlashLog('\nFlash complete! You can now Connect to use the device.\n'); } catch (err) { - appendToTerminal(`\nFlash error: ${err.message}\n`); + appendFlashLog(`\nFlash error: ${err.message}\n`); + } finally { + flashCloseBtn.classList.remove('hidden'); } }); diff --git a/src/serial/flasher.js b/src/serial/flasher.js index c9cc322..cc2e225 100644 --- a/src/serial/flasher.js +++ b/src/serial/flasher.js @@ -1,51 +1,120 @@ -// esptool-js firmware flasher wrapper -// Actual flashing will use the esptool-js ESPLoader when the user triggers it. +import { ESPLoader, Transport } from 'esptool-js'; -export async function flashFirmware(port, onProgress) { - const { ESPLoader, Transport } = await import('esptool-js'); +const FIRMWARE_URL = '/firmware/ESP32_GENERIC_S3-20251209-v1.27.0.bin'; + +function arrayBufferToBinaryString(buffer) { + const bytes = new Uint8Array(buffer); + const chunks = []; + const chunkSize = 8192; + for (let i = 0; i < bytes.length; i += chunkSize) { + const slice = bytes.subarray(i, i + chunkSize); + chunks.push(String.fromCharCode.apply(null, slice)); + } + return chunks.join(''); +} + +export async function flashFirmware(onLog, onProgress) { + const port = await navigator.serial.requestPort(); + + // Buffer to track message state for proper newline insertion + let messageBuffer = ''; + + function normalizeAndLog(data) { + messageBuffer += data; + + // Detect patterns that indicate message boundaries and insert newlines + // Pattern 1: "Writing at" should always start on a new line (unless already at start/newline) + messageBuffer = messageBuffer.replace(/([^\n])(Writing at)/g, '$1\n$2'); + + // Pattern 2: Messages ending with "%)" followed by "Writing at" need a newline + messageBuffer = messageBuffer.replace(/(%\))(Writing at)/g, '$1\n$2'); + + // Pattern 3: Messages ending with "." or "..." followed by capital letters (like "Leaving...") + messageBuffer = messageBuffer.replace(/(\.\.?)([A-Z])/g, '$1\n$2'); + + // Pattern 4: "Wrote" messages should be on their own line + messageBuffer = messageBuffer.replace(/([^\n])(Wrote \d+ bytes)/g, '$1\n$2'); + + // Split on newlines and process complete lines + const lines = messageBuffer.split('\n'); + // Keep the last potentially incomplete line in buffer + messageBuffer = lines.pop() || ''; + + // Log all complete lines + for (const line of lines) { + if (line.trim()) { + onLog?.(line + '\n'); + } + } + } + + function flushBuffer() { + if (messageBuffer.trim()) { + onLog?.(messageBuffer + '\n'); + messageBuffer = ''; + } + } const transport = new Transport(port); - const loader = new ESPLoader({ + const esploader = new ESPLoader({ transport, baudrate: 115200, + romBaudrate: 115200, terminal: { clean() {}, - writeLine(data) { onProgress?.(data); }, - write(data) { onProgress?.(data); }, + writeLine(data) { + // Flush any buffered content first + flushBuffer(); + onLog?.(data + (data.endsWith('\n') ? '' : '\n')); + }, + write(data) { + normalizeAndLog(data); + }, }, }); - await loader.main(); - await loader.eraseFlash(); + onLog?.('Connecting to ESP32-S3...\n'); + const chip = await esploader.main(); + flushBuffer(); + onLog?.(`Detected: ${chip}\n`); - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.bin'; + onLog?.('Fetching firmware...\n'); + const resp = await fetch(FIRMWARE_URL); + if (!resp.ok) throw new Error(`Failed to fetch firmware: ${resp.status}`); + const firmwareBuf = await resp.arrayBuffer(); + const firmwareBin = arrayBufferToBinaryString(firmwareBuf); + onLog?.(`Firmware size: ${(firmwareBuf.byteLength / 1024).toFixed(0)} KB\n`); - return new Promise((resolve, reject) => { - input.onchange = async () => { - try { - const file = input.files[0]; - if (!file) return reject(new Error('No file selected')); + onLog?.('Erasing flash...\n'); + await esploader.eraseFlash(); + flushBuffer(); + onLog?.('Erase complete.\n'); - const data = await file.arrayBuffer(); - const uint8 = new Uint8Array(data); - - onProgress?.('Writing firmware...'); - await loader.writeFlash({ - fileArray: [{ data: uint8, address: 0x0 }], - flashSize: 'keep', - eraseAll: false, - compress: true, - }); - - onProgress?.('Firmware flashed successfully!'); - await transport.disconnect(); - resolve(); - } catch (err) { - reject(err); - } - }; - input.click(); + onLog?.('Writing firmware at 0x0...\n'); + await esploader.writeFlash({ + fileArray: [{ data: firmwareBin, address: 0x0 }], + flashSize: 'keep', + flashMode: 'keep', + flashFreq: 'keep', + eraseAll: false, + compress: true, + reportProgress(fileIndex, written, total) { + const pct = Math.round((written / total) * 100); + onProgress?.(pct); + }, }); + flushBuffer(); + + onLog?.('\nFirmware written successfully!\n'); + onLog?.('Hard resetting device...\n'); + await esploader.after('hard_reset'); + flushBuffer(); + + try { + await transport.disconnect(); + } catch (_) { + // already closed + } + + onLog?.('Done! Device is ready.\n'); } diff --git a/src/style.css b/src/style.css index 7d1468c..834d6c8 100644 --- a/src/style.css +++ b/src/style.css @@ -260,3 +260,89 @@ html, body { ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* --- Flash Progress Overlay --- */ +.hidden { display: none !important; } + +#flash-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +#flash-modal { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 32px; + width: 520px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + gap: 14px; +} + +#flash-modal h3 { + margin: 0; + color: var(--accent); + font-size: 16px; +} + +#flash-log { + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 10px 12px; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} + +#flash-progress-bar { + height: 10px; + background: var(--bg-secondary); + border-radius: 5px; + overflow: hidden; +} + +#flash-progress-fill { + height: 100%; + width: 0%; + background: var(--accent); + border-radius: 5px; + transition: width 0.2s ease; +} + +#flash-progress-text { + font-size: 13px; + color: var(--text-secondary); + text-align: center; +} + +#flash-close { + align-self: flex-end; + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 20px; + cursor: pointer; + font-size: 13px; + transition: background 0.15s, border-color 0.15s; +} + +#flash-close:hover { + background: var(--accent); + color: var(--bg-toolbar); + border-color: var(--accent); +}