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);