diff --git a/static/settings_editor.html b/static/settings_editor.html index c714c27..d7ce41b 100644 --- a/static/settings_editor.html +++ b/static/settings_editor.html @@ -185,10 +185,11 @@ document.body.getAttribute('data-api-base') ?? (location.pathname.includes('/led-tool') ? '/led-tool' : ''); const prefix = api + '/static'; + const v = '20260520'; function loadScript(url) { return new Promise((resolve, reject) => { const s = document.createElement('script'); - s.src = url; + s.src = url + (url.includes('?') ? '&' : '?') + 'v=' + v; s.onload = () => resolve(); s.onerror = () => reject(new Error('Failed to load ' + url)); document.body.appendChild(s); diff --git a/static/settings_editor.js b/static/settings_editor.js index a354ac1..f3ab2bf 100644 --- a/static/settings_editor.js +++ b/static/settings_editor.js @@ -24,8 +24,10 @@ let hostConnected = false; let hostConnectedPort = ''; let transferBusy = false; + let webConnectBusy = false; const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported(); + let webSerialGrantedCount = 0; function setStatus(text, isError) { if (!statusEl) return; @@ -40,6 +42,17 @@ setTimeout(() => messageEl.classList.remove('show'), 5000); } + function errorMessage(e) { + if (!e) return 'Unknown error'; + if (typeof e === 'string') return e; + return e.message || String(e); + } + + function onWebSerialTransferError(e) { + if (webClient) webClient.inRawRepl = false; + return errorMessage(e); + } + function formToObject() { const out = {}; document.querySelectorAll('[data-setting]').forEach((el) => { @@ -99,7 +112,11 @@ } else if (hostOpen) { setStatus(`Host serial: ${hostConnectedPort} (use Download or Upload).`); } else if (webSerialOk) { - setStatus('Connect host serial or Web Serial USB, then Download or Upload.'); + if (webSerialGrantedCount === 1) { + setStatus('Connect USB (one remembered port) or host serial, then Download or Upload.'); + } else { + setStatus('Connect host serial or Web Serial USB, then Download or Upload.'); + } } else { setStatus(`Connect host serial (default ${DEFAULT_HOST_PORT}).`); } @@ -205,21 +222,31 @@ } async function connectWebSerial() { + if (webConnectBusy) return; if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) { flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true); return; } + if (usingWebSerial()) { + flash('USB already connected.', false); + return; + } if (usingHostSerial()) { disconnectHostSerial(); } - log('connectWebSerial: requestPort'); - setStatus('Opening USB port…'); - webPort = await window.LedToolWebSerial.requestPort(); - log('connectWebSerial: port selected', webPort); - webClient = new window.LedToolWebSerial.MicroPythonRawRepl(); - await webClient.connect(webPort); - updateUi(); - flash('USB port open. Use Download or Upload to talk to the device.', false); + webConnectBusy = true; + try { + log('connectWebSerial: requestPort'); + setStatus('Opening USB port…'); + webPort = await window.LedToolWebSerial.requestPort(); + log('connectWebSerial: port selected', webPort); + webClient = new window.LedToolWebSerial.MicroPythonRawRepl(); + await webClient.connect(webPort); + updateUi(); + flash('USB port open. Wait for boot, then Download.', false); + } finally { + webConnectBusy = false; + } } async function disconnectWebSerial() { @@ -284,9 +311,10 @@ } } catch (e) { console.error(LOG, 'download failed', e); - if (rawJson) rawJson.value = String(e.message || e); - flash(e.message, true); - setStatus(e.message, true); + const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e); + if (rawJson) rawJson.value = msg; + flash(msg, true); + setStatus(msg, true); } finally { transferBusy = false; } @@ -310,8 +338,9 @@ } } catch (e) { console.error(LOG, 'upload failed', e); - flash(e.message, true); - setStatus(e.message, true); + const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e); + flash(msg, true); + setStatus(msg, true); } finally { transferBusy = false; } @@ -374,7 +403,19 @@ } }); + async function refreshWebSerialGrantHint() { + if (!webSerialOk || !window.LedToolWebSerial.getGrantedPorts) return; + try { + const ports = await window.LedToolWebSerial.getGrantedPorts(); + webSerialGrantedCount = ports.length; + updateUi(); + } catch (_) { + /* ignore */ + } + } + log('settings editor ready', { apiBase, webSerialOk }); updateUi(); loadHostPorts(); + refreshWebSerialGrantHint(); })(); diff --git a/static/web_serial.js b/static/web_serial.js index 14c1624..7931f4d 100644 --- a/static/web_serial.js +++ b/static/web_serial.js @@ -3,7 +3,7 @@ */ (function (global) { const RAW_REPL_BANNER = 'raw REPL; CTRL-B to exit\r\n'; - const SOFT_REBOOT = 'soft reboot\r\n'; + const SOFT_REBOOT_MARKERS = ['soft reboot\r\n', 'MPY: soft reboot\r\n']; const LOG = '[led-tool/serial]'; function log(...args) { @@ -29,12 +29,65 @@ return new Promise((resolve) => setTimeout(resolve, ms)); } - function bytesEndsWith(buf, suffix) { - if (buf.length < suffix.length) return false; - for (let i = 0; i < suffix.length; i += 1) { - if (buf[buf.length - suffix.length + i] !== suffix[i]) return false; + /** Common ESP32 USB-UART bridges (usbVendorId only per Web Serial API). */ + const ESP32_USB_FILTERS = [ + { usbVendorId: 0x303a }, /* Espressif */ + { usbVendorId: 0x10c4 }, /* Silicon Labs CP210x */ + { usbVendorId: 0x1a86 }, /* WCH CH340 */ + { usbVendorId: 0x0403 }, /* FTDI */ + ]; + + async function deassertDtrRts(port) { + if (!port || !port.setSignals) return; + try { + await port.setSignals({ dataTerminalReady: false, requestToSend: false }); + } catch (_) { + /* ignore — some adapters/browsers reject setSignals */ } - return true; + } + + function bytesIndexOf(buf, suffix) { + if (!suffix.length) return 0; + if (buf.length < suffix.length) return -1; + for (let i = 0; i <= buf.length - suffix.length; i += 1) { + let ok = true; + for (let j = 0; j < suffix.length; j += 1) { + if (buf[i + j] !== suffix[j]) { + ok = false; + break; + } + } + if (ok) return i; + } + return -1; + } + + function bufferEndsWithFriendlyRepl(buf) { + if (bufferLooksLikeEspRomBoot(buf) && !bufferHasMicroPython(buf)) return false; + const tail = decodeText(Uint8Array.from(buf.slice(-80))); + return /(?:\r\n|\n)>>> ?$/.test(tail); + } + + function bufferLooksLikeEspRomBoot(buf) { + if (bufferIndicatesRuntime(buf)) return false; + const text = decodeText(Uint8Array.from(buf.slice(-512))); + return /SPI_FAST_FLASH_BOOT|rst:0x/.test(text) && !/entry 0x403/.test(text); + } + + /** MicroPython is up (banner, REPL, raw REPL, or post-ROM heap lines from boot). */ + function bufferIndicatesRuntime(buf) { + const text = decodeText(Uint8Array.from(buf)); + if (text.includes('MicroPython')) return true; + if (text.includes('raw REPL; CTRL-B to exit')) return true; + if (/(?:\r\n|\n)>>> ?/.test(text)) return true; + if (text.includes('entry 0x') && (/['"]?free['"]?\s*:/.test(text) || /,\s*'free'/.test(text))) { + return true; + } + return false; + } + + function bufferHasMicroPython(buf) { + return bufferIndicatesRuntime(buf); } function decodeText(u8) { @@ -84,12 +137,15 @@ return result.value || null; } + /** Match suffix anywhere in the buffer; keep bytes after the match for the next read. */ async readUntil(suffixBytes, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (bytesEndsWith(this._rxBuf, suffixBytes)) { - const matched = Uint8Array.from(this._rxBuf); - this._rxBuf.length = 0; + const idx = bytesIndexOf(this._rxBuf, suffixBytes); + if (idx >= 0) { + const end = idx + suffixBytes.length; + const matched = Uint8Array.from(this._rxBuf.slice(0, end)); + this._rxBuf.splice(0, end); logDebug('readUntil matched', JSON.stringify(decodeText(suffixBytes)), 'len', matched.length); return matched; } @@ -106,6 +162,29 @@ throw new Error(err); } + async readUntilAny(suffixList, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const suffixBytes of suffixList) { + const idx = bytesIndexOf(this._rxBuf, suffixBytes); + if (idx >= 0) { + const end = idx + suffixBytes.length; + const matched = Uint8Array.from(this._rxBuf.slice(0, end)); + this._rxBuf.splice(0, end); + logDebug('readUntilAny matched', JSON.stringify(decodeText(suffixBytes))); + return matched; + } + } + const chunk = await this._readStreamChunk(Math.min(300, Math.max(1, deadline - Date.now()))); + if (chunk && chunk.length) this._appendRx(chunk); + else await sleep(5); + } + const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240))); + throw new Error( + `Timed out waiting for one of ${suffixList.map((s) => JSON.stringify(decodeText(s))).join(', ')} (tail: ${JSON.stringify(tail)})`, + ); + } + async readExact(n, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; while (this._rxBuf.length < n && Date.now() < deadline) { @@ -132,21 +211,38 @@ flowControl: 'none', bufferSize: 65536, }); - if (this.port.setSignals) { - try { - await this.port.setSignals({ dataTerminalReady: false, requestToSend: false }); - } catch (_) { - /* ignore */ - } - } + await deassertDtrRts(this.port); + await sleep(50); + await deassertDtrRts(this.port); this.reader = this.port.readable.getReader(); this.writer = this.port.writable.getWriter(); this.portOpen = true; await sleep(100); - await this.drainInput(300); + await this.collectIncoming(8000); + if (bufferIndicatesRuntime(this._rxBuf)) { + log('connect: MicroPython output seen during open'); + } log('connect: port open (raw REPL not entered yet)'); } + /** Read serial into _rxBuf without discarding (for post-open boot). */ + async collectIncoming(maxMs) { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + const chunk = await this._readStreamChunk(Math.min(80, Math.max(1, deadline - Date.now()))); + if (chunk && chunk.length) this._appendRx(chunk); + else if ( + bufferHasMicroPython(this._rxBuf) || + bufferEndsWithFriendlyRepl(this._rxBuf) || + bytesIndexOf(this._rxBuf, new TextEncoder().encode(RAW_REPL_BANNER)) >= 0 + ) { + break; + } else { + await sleep(15); + } + } + } + async ensureRawRepl() { if (this.inRawRepl) return; if (this._replLock) { @@ -154,7 +250,8 @@ return; } log('ensureRawRepl: entering raw REPL'); - this._replLock = this.enterRawRepl(true).finally(() => { + // USB open already resets the ESP32; skip soft-reset (unlike host led-cli/mpremote). + this._replLock = this.enterRawRepl(false).finally(() => { this._replLock = null; }); await this._replLock; @@ -193,6 +290,11 @@ this.writer = null; } if (this.port) { + try { + await deassertDtrRts(this.port); + } catch (_) { + /* ignore */ + } try { await this.port.close(); } catch (_) { @@ -209,41 +311,147 @@ await this.writer.write(data); } - async drainInput(maxMs = 400) { + async drainInput(maxMs = 400, clearBuffer = true) { const deadline = Date.now() + maxMs; while (Date.now() < deadline) { const chunk = await this._readStreamChunk(40); if (!chunk || !chunk.length) break; + if (!clearBuffer && chunk.length) this._appendRx(chunk); } - this._rxBuf.length = 0; + if (clearBuffer) this._rxBuf.length = 0; + } + + async sendCtrlA() { + await this.writeBytes(new Uint8Array([0x0d, 0x01])); + } + + /** USB open often resets the ESP32; wait through ROM boot until MicroPython is up. */ + async waitForMicroPythonBoot(timeoutMs = 60000) { + log('enterRawRepl: wait for MicroPython after boot', 'buf', this._rxBuf.length); + if (bufferHasMicroPython(this._rxBuf) || bufferEndsWithFriendlyRepl(this._rxBuf)) { + log('enterRawRepl: MicroPython already in buffer'); + return; + } + const deadline = Date.now() + timeoutMs; + let nudgeSent = false; + let lastLog = Date.now(); + while (Date.now() < deadline) { + if (bufferHasMicroPython(this._rxBuf) || bufferEndsWithFriendlyRepl(this._rxBuf)) { + log('enterRawRepl: MicroPython running'); + return; + } + const text = decodeText(Uint8Array.from(this._rxBuf)); + if (!nudgeSent && text.includes('entry 0x')) { + nudgeSent = true; + log('enterRawRepl: past ROM loader, nudge REPL'); + await this.writeBytes(new Uint8Array([0x0d])); + await sleep(200); + } + if (Date.now() - lastLog > 5000) { + lastLog = Date.now(); + log('enterRawRepl: still waiting…', 'buf', this._rxBuf.length); + } + const rem = Math.max(1, deadline - Date.now()); + const chunk = await this._readStreamChunk(Math.min(300, rem)); + if (chunk && chunk.length) this._appendRx(chunk); + else await sleep(bufferLooksLikeEspRomBoot(this._rxBuf) ? 20 : 10); + } + const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240))); + throw new Error(`Timed out waiting for MicroPython boot (tail: ${JSON.stringify(tail)})`); + } + + /** Wait until raw REPL banner and `>` prompt (not banner alone — device may still be booting). */ + async waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const rem = Math.max(1, deadline - Date.now()); + const promptAt = bytesIndexOf(this._rxBuf, bannerPromptBytes); + if (promptAt >= 0) { + this._rxBuf.splice(0, promptAt + bannerPromptBytes.length); + log('enterRawRepl: raw REPL prompt ready'); + return; + } + if (bufferEndsWithFriendlyRepl(this._rxBuf)) { + log('enterRawRepl: friendly REPL, ctrl-A'); + await this.sendCtrlA(); + await sleep(30); + continue; + } + if (bufferLooksLikeEspRomBoot(this._rxBuf) && !bufferHasMicroPython(this._rxBuf)) { + logDebug('enterRawRepl: ROM boot in progress…'); + } + const chunk = await this._readStreamChunk(Math.min(300, rem)); + if (chunk && chunk.length) this._appendRx(chunk); + else await sleep(10); + } + const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240))); + throw new Error( + `Timed out waiting for raw REPL after soft reboot (tail: ${JSON.stringify(tail)})`, + ); } /** Match mpremote SerialTransport.enter_raw_repl(soft_reset). */ async enterRawRepl(softReset = true) { log('enterRawRepl', { softReset }); - await this.writeBytes(new Uint8Array([0x0d, 0x03])); - await this.drainInput(400); - await this.writeBytes(new Uint8Array([0x0d, 0x01])); + const bannerBytes = new TextEncoder().encode(RAW_REPL_BANNER); + const bannerPromptBytes = new TextEncoder().encode(RAW_REPL_BANNER + '>'); + const softRebootBytes = SOFT_REBOOT_MARKERS.map((m) => new TextEncoder().encode(m)); - if (softReset) { - log('enterRawRepl: wait banner+>, soft reset'); - await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER + '>'), 15000); - await this.writeBytes(new Uint8Array([0x04])); + if ( + !bufferHasMicroPython(this._rxBuf) && + !bufferEndsWithFriendlyRepl(this._rxBuf) && + bytesIndexOf(this._rxBuf, bannerBytes) < 0 + ) { + await this.waitForMicroPythonBoot(60000); + } else { + log('enterRawRepl: already have REPL output in buffer'); + } + + if (bufferEndsWithFriendlyRepl(this._rxBuf)) { + await this.writeBytes(new Uint8Array([0x0d, 0x03])); + await sleep(50); + } + + let ctrlARetries = 0; + const maxCtrlARetries = 3; + while (ctrlARetries <= maxCtrlARetries) { try { - await this.readUntil(new TextEncoder().encode(SOFT_REBOOT), 15000); - log('enterRawRepl: saw soft reboot'); + await this.sendCtrlA(); + await this.waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, 20000); + break; } catch (e) { - log('enterRawRepl: no soft reboot banner', e.message); + if (ctrlARetries >= maxCtrlARetries) throw e; + ctrlARetries += 1; + log('enterRawRepl: retry ctrl-A', ctrlARetries, e.message); + this._rxBuf.length = 0; } } - try { - await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 45000); - } catch (e) { - log('enterRawRepl: retry ctrl-A after', e.message); - await this.writeBytes(new Uint8Array([0x0d, 0x01])); - await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 30000); + if (softReset) { + log('enterRawRepl: soft reset'); + await this.writeBytes(new Uint8Array([0x04])); + try { + await this.readUntilAny(softRebootBytes, 20000); + log('enterRawRepl: saw soft reboot'); + this._rxBuf.length = 0; + } catch (e) { + log('enterRawRepl: no soft reboot banner', e.message); + } + ctrlARetries = 0; + while (ctrlARetries <= maxCtrlARetries) { + try { + await this.waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, 60000); + break; + } catch (e) { + if (ctrlARetries >= maxCtrlARetries) throw e; + ctrlARetries += 1; + log('enterRawRepl: post-reboot retry ctrl-A', ctrlARetries, e.message); + this._rxBuf.length = 0; + await this.sendCtrlA(); + } + } } + this.inRawRepl = true; log('enterRawRepl: ready'); } @@ -275,9 +483,34 @@ await this.readUntil(new Uint8Array([0x04]), 30000); } + async awaitRawReplExecPrompt(timeoutMs = 60000) { + const bannerPromptBytes = new TextEncoder().encode(RAW_REPL_BANNER + '>'); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const promptAt = bytesIndexOf(this._rxBuf, bannerPromptBytes); + if (promptAt >= 0) { + this._rxBuf.splice(0, promptAt + bannerPromptBytes.length); + return; + } + if (bufferEndsWithFriendlyRepl(this._rxBuf)) { + log('exec: friendly REPL before prompt, ctrl-A'); + this._rxBuf.length = 0; + await this.sendCtrlA(); + } else if (bufferLooksLikeEspRomBoot(this._rxBuf)) { + logDebug('exec: still in ROM boot, waiting…'); + } + const rem = Math.max(1, deadline - Date.now()); + const chunk = await this._readStreamChunk(Math.min(300, rem)); + if (chunk && chunk.length) this._appendRx(chunk); + else await sleep(10); + } + const tail = decodeText(Uint8Array.from(this._rxBuf.slice(-240))); + throw new Error(`Timed out waiting for raw REPL prompt (tail: ${JSON.stringify(tail)})`); + } + async execRawNoFollow(commandBytes) { log('execRawNoFollow', commandBytes.length, 'bytes'); - await this.readUntil(new TextEncoder().encode('>'), 10000); + await this.awaitRawReplExecPrompt(60000); if (this.useRawPaste) { await this.writeBytes(new Uint8Array([0x05, 0x41, 0x01])); @@ -388,9 +621,33 @@ } } + function serialSupported() { + return typeof navigator !== 'undefined' && 'serial' in navigator; + } + + async function getGrantedPorts() { + if (!serialSupported()) return []; + return navigator.serial.getPorts(); + } + + async function requestPort() { + if (!serialSupported()) { + throw new Error('Web Serial not supported'); + } + const granted = await navigator.serial.getPorts(); + if (granted.length === 1) { + log('requestPort: reusing single granted port'); + return granted[0]; + } + log('requestPort: showing picker', { grantedCount: granted.length }); + return navigator.serial.requestPort({ filters: ESP32_USB_FILTERS }); + } + global.LedToolWebSerial = { - supported: () => typeof navigator !== 'undefined' && 'serial' in navigator, - requestPort: () => navigator.serial.requestPort(), + supported: serialSupported, + getGrantedPorts, + requestPort, MicroPythonRawRepl, + _test: { bytesIndexOf, decodeText }, }; })(typeof window !== 'undefined' ? window : globalThis);