Compare commits

5 Commits

Author SHA1 Message Date
2961ad2a29 test(editor): web serial readuntil buffer regression
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 22:00:20 +12:00
35c0df8f88 docs: update readme for serial baud and deploy paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 22:00:20 +12:00
5c97fa0d0b feat(editor): add bridge ap ip and password fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 22:00:20 +12:00
179ac9c540 feat(cli): add --serial-baudrate for bridge uart uplink
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 22:00:14 +12:00
bd4d2060ae fix(led-tool): harden Web Serial raw REPL connect
Improve boot wait, readUntil buffer handling, and settings editor
host/Web Serial flows after device reset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:08 +12:00
6 changed files with 458 additions and 58 deletions

View File

@@ -20,13 +20,14 @@ Connection is always via **`-p` / `--port`** (default `/dev/ttyACM0`). There is
| `-o`, `--order` | LED colour order (`rgb`, `grb`, …) | | `-o`, `--order` | LED colour order (`rgb`, `grb`, …) |
| `--preset` / `--pattern` | Create or replace a named preset in **led-driver** `presets.json` | | `--preset` / `--pattern` | Create or replace a named preset in **led-driver** `presets.json` |
| `--default` | Startup preset name | | `--default` | Startup preset name |
| `--transport` | `espnow` or `wifi` (`transport_type` on device) | | `--transport` | `espnow` or `wifi` (`transport_type` on led-driver) |
| `--serial-baudrate` | Bridge UART1 baud for Pi serial link (e.g. `921600`; also sets GPIO UART pins) |
| `--ssid`, `--wifi-password`, `--wifi-channel` | Wi-Fi / channel fields for the driver | | `--ssid`, `--wifi-password`, `--wifi-channel` | Wi-Fi / channel fields for the driver |
| `-r`, `--reset` | Reset the device | | `-r`, `--reset` | Reset the device |
| `-f`, `--follow` | Follow serial output (optional timeout seconds) | | `-f`, `--follow` | Follow serial output (optional timeout seconds) |
| `--pause` | Sleep N seconds (for chained actions) | | `--pause` | Sleep N seconds (for chained actions) |
| `-u`, `--upload` | Recursive upload: `-u SRC [DEST]` (skips unchanged files via `file_hashes.json` on device) | | `-u`, `--upload` | Recursive upload: `-u SRC [DEST]` (skips unchanged files via `file_hashes.json` on device) |
| `--src`, `--lib`, `--all` | Deploy led-driver trees to flash root, `patterns/`, and `lib/` | | `--src`, `--lib`, `--all` | Deploy `src/` to device root and `lib/` to `/lib` (led-driver, espnow-sender, …) |
| `--force-upload` | Upload every file; ignore `file_hashes.json` | | `--force-upload` | Upload every file; ignore `file_hashes.json` |
| `-e`, `--erase` | Erase everything at device root (including `settings.json` and `presets.json`) | | `-e`, `--erase` | Erase everything at device root (including `settings.json` and `presets.json`) |
| `--rm` | Remove a path on the device | | `--rm` | Remove a path on the device |
@@ -51,7 +52,7 @@ python web.py
# open http://<host>:5000/editor # open http://<host>:5000/editor
``` ```
**Embedded in led-controller:** open **LED Tool** in the main UI, or visit **`/led-tool/editor`**. **Embedded in led-controller:** open **Settings → LED Tool** in the main UI (Edit mode), or visit **`/led-tool/editor`**.
Legacy Flask form UI remains at **`/`** on port 5000; prefer **`/editor`** for Web Serial support. Legacy Flask form UI remains at **`/`** on port 5000; prefer **`/editor`** for Web Serial support.

25
cli.py
View File

@@ -150,7 +150,8 @@ _FLAGS_WITH_VALUE = frozenset({
'-p', '--port', '-n', '--name', '--pin', '-b', '--brightness', '-p', '--port', '-n', '--name', '--pin', '-b', '--brightness',
'-l', '--leds', '-d', '-debug', '--debug', '-o', '--order', '-l', '--leds', '-d', '-debug', '--debug', '-o', '--order',
'--preset', '--pattern', '--default', '--transport', '--ssid', '--preset', '--pattern', '--default', '--transport', '--ssid',
'--wifi-password', '--wifi-channel', '--src', '--lib', '--patterns', '--paterns', '--wifi-password', '--wifi-channel', '--serial-baudrate',
'--src', '--lib', '--patterns', '--paterns',
}) })
@@ -321,6 +322,9 @@ Examples:
# Reset logical device name to firmware default (STA MAC based) # Reset logical device name to firmware default (STA MAC based)
%(prog)s --reset-device-name %(prog)s --reset-device-name
# ESP-NOW bridge: Pi on GPIO UART1 (USB-serial adapter)
%(prog)s -p /dev/ttyUSB0 --serial-baudrate 921600
""" """
) )
@@ -413,6 +417,13 @@ Examples:
help="led-driver transport_type", help="led-driver transport_type",
) )
parser.add_argument(
"--serial-baudrate",
type=int,
metavar="BAUD",
help="bridge: UART1 baud for Pi serial link (default 921600)",
)
parser.add_argument( parser.add_argument(
"--ssid", "--ssid",
help="led-driver ssid (Wi-Fi network in wifi mode)", help="led-driver ssid (Wi-Fi network in wifi mode)",
@@ -747,6 +758,18 @@ Examples:
if args.transport is not None: if args.transport is not None:
edits["transport_type"] = args.transport edits["transport_type"] = args.transport
if args.serial_baudrate is not None:
edits["uplink_transport"] = "serial"
edits["serial_usb"] = False
edits["serial_uart_id"] = 1
edits["serial_tx_pin"] = 2
edits["serial_rx_pin"] = 3
baud = int(args.serial_baudrate)
if baud < 9600 or baud > 3000000:
print("Error: --serial-baudrate must be 96003000000", file=sys.stderr)
sys.exit(1)
edits["serial_baudrate"] = baud
if args.ssid is not None: if args.ssid is not None:
edits["ssid"] = args.ssid edits["ssid"] = args.ssid

View File

@@ -155,6 +155,14 @@
<label for="wifi_channel">WiFi channel</label> <label for="wifi_channel">WiFi channel</label>
<input id="wifi_channel" data-setting="wifi_channel" type="number" min="1" max="11" /> <input id="wifi_channel" data-setting="wifi_channel" type="number" min="1" max="11" />
</div> </div>
<div>
<label for="ap_ip">Bridge AP IP</label>
<input id="ap_ip" data-setting="ap_ip" type="text" placeholder="192.168.4.1" />
</div>
<div>
<label for="ap_password">Bridge AP password</label>
<input id="ap_password" data-setting="ap_password" type="password" placeholder="min 8 chars, or empty for open" />
</div>
<div> <div>
<label for="default">Default preset</label> <label for="default">Default preset</label>
<input id="default" data-setting="default" type="text" /> <input id="default" data-setting="default" type="text" />
@@ -185,10 +193,11 @@
document.body.getAttribute('data-api-base') ?? document.body.getAttribute('data-api-base') ??
(location.pathname.includes('/led-tool') ? '/led-tool' : ''); (location.pathname.includes('/led-tool') ? '/led-tool' : '');
const prefix = api + '/static'; const prefix = api + '/static';
const v = '20260520';
function loadScript(url) { function loadScript(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const s = document.createElement('script'); const s = document.createElement('script');
s.src = url; s.src = url + (url.includes('?') ? '&' : '?') + 'v=' + v;
s.onload = () => resolve(); s.onload = () => resolve();
s.onerror = () => reject(new Error('Failed to load ' + url)); s.onerror = () => reject(new Error('Failed to load ' + url));
document.body.appendChild(s); document.body.appendChild(s);

View File

@@ -24,8 +24,10 @@
let hostConnected = false; let hostConnected = false;
let hostConnectedPort = ''; let hostConnectedPort = '';
let transferBusy = false; let transferBusy = false;
let webConnectBusy = false;
const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported(); const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported();
let webSerialGrantedCount = 0;
function setStatus(text, isError) { function setStatus(text, isError) {
if (!statusEl) return; if (!statusEl) return;
@@ -40,6 +42,17 @@
setTimeout(() => messageEl.classList.remove('show'), 5000); 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() { function formToObject() {
const out = {}; const out = {};
document.querySelectorAll('[data-setting]').forEach((el) => { document.querySelectorAll('[data-setting]').forEach((el) => {
@@ -99,7 +112,11 @@
} else if (hostOpen) { } else if (hostOpen) {
setStatus(`Host serial: ${hostConnectedPort} (use Download or Upload).`); setStatus(`Host serial: ${hostConnectedPort} (use Download or Upload).`);
} else if (webSerialOk) { } 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 { } else {
setStatus(`Connect host serial (default ${DEFAULT_HOST_PORT}).`); setStatus(`Connect host serial (default ${DEFAULT_HOST_PORT}).`);
} }
@@ -205,21 +222,31 @@
} }
async function connectWebSerial() { async function connectWebSerial() {
if (webConnectBusy) return;
if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) { if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) {
flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true); flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true);
return; return;
} }
if (usingWebSerial()) {
flash('USB already connected.', false);
return;
}
if (usingHostSerial()) { if (usingHostSerial()) {
disconnectHostSerial(); disconnectHostSerial();
} }
log('connectWebSerial: requestPort'); webConnectBusy = true;
setStatus('Opening USB port…'); try {
webPort = await window.LedToolWebSerial.requestPort(); log('connectWebSerial: requestPort');
log('connectWebSerial: port selected', webPort); setStatus('Opening USB port…');
webClient = new window.LedToolWebSerial.MicroPythonRawRepl(); webPort = await window.LedToolWebSerial.requestPort();
await webClient.connect(webPort); log('connectWebSerial: port selected', webPort);
updateUi(); webClient = new window.LedToolWebSerial.MicroPythonRawRepl();
flash('USB port open. Use Download or Upload to talk to the device.', false); await webClient.connect(webPort);
updateUi();
flash('USB port open. Wait for boot, then Download.', false);
} finally {
webConnectBusy = false;
}
} }
async function disconnectWebSerial() { async function disconnectWebSerial() {
@@ -284,9 +311,10 @@
} }
} catch (e) { } catch (e) {
console.error(LOG, 'download failed', e); console.error(LOG, 'download failed', e);
if (rawJson) rawJson.value = String(e.message || e); const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
flash(e.message, true); if (rawJson) rawJson.value = msg;
setStatus(e.message, true); flash(msg, true);
setStatus(msg, true);
} finally { } finally {
transferBusy = false; transferBusy = false;
} }
@@ -310,8 +338,9 @@
} }
} catch (e) { } catch (e) {
console.error(LOG, 'upload failed', e); console.error(LOG, 'upload failed', e);
flash(e.message, true); const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
setStatus(e.message, true); flash(msg, true);
setStatus(msg, true);
} finally { } finally {
transferBusy = false; 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 }); log('settings editor ready', { apiBase, webSerialOk });
updateUi(); updateUi();
loadHostPorts(); loadHostPorts();
refreshWebSerialGrantHint();
})(); })();

View File

@@ -3,7 +3,7 @@
*/ */
(function (global) { (function (global) {
const RAW_REPL_BANNER = 'raw REPL; CTRL-B to exit\r\n'; 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]'; const LOG = '[led-tool/serial]';
function log(...args) { function log(...args) {
@@ -29,12 +29,65 @@
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function bytesEndsWith(buf, suffix) { /** Common ESP32 USB-UART bridges (usbVendorId only per Web Serial API). */
if (buf.length < suffix.length) return false; const ESP32_USB_FILTERS = [
for (let i = 0; i < suffix.length; i += 1) { { usbVendorId: 0x303a }, /* Espressif */
if (buf[buf.length - suffix.length + i] !== suffix[i]) return false; { 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) { function decodeText(u8) {
@@ -84,12 +137,15 @@
return result.value || null; return result.value || null;
} }
/** Match suffix anywhere in the buffer; keep bytes after the match for the next read. */
async readUntil(suffixBytes, timeoutMs = 15000) { async readUntil(suffixBytes, timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (bytesEndsWith(this._rxBuf, suffixBytes)) { const idx = bytesIndexOf(this._rxBuf, suffixBytes);
const matched = Uint8Array.from(this._rxBuf); if (idx >= 0) {
this._rxBuf.length = 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); logDebug('readUntil matched', JSON.stringify(decodeText(suffixBytes)), 'len', matched.length);
return matched; return matched;
} }
@@ -106,6 +162,29 @@
throw new Error(err); 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) { async readExact(n, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (this._rxBuf.length < n && Date.now() < deadline) { while (this._rxBuf.length < n && Date.now() < deadline) {
@@ -132,21 +211,38 @@
flowControl: 'none', flowControl: 'none',
bufferSize: 65536, bufferSize: 65536,
}); });
if (this.port.setSignals) { await deassertDtrRts(this.port);
try { await sleep(50);
await this.port.setSignals({ dataTerminalReady: false, requestToSend: false }); await deassertDtrRts(this.port);
} catch (_) {
/* ignore */
}
}
this.reader = this.port.readable.getReader(); this.reader = this.port.readable.getReader();
this.writer = this.port.writable.getWriter(); this.writer = this.port.writable.getWriter();
this.portOpen = true; this.portOpen = true;
await sleep(100); 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)'); 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() { async ensureRawRepl() {
if (this.inRawRepl) return; if (this.inRawRepl) return;
if (this._replLock) { if (this._replLock) {
@@ -154,7 +250,8 @@
return; return;
} }
log('ensureRawRepl: entering raw REPL'); 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; this._replLock = null;
}); });
await this._replLock; await this._replLock;
@@ -193,6 +290,11 @@
this.writer = null; this.writer = null;
} }
if (this.port) { if (this.port) {
try {
await deassertDtrRts(this.port);
} catch (_) {
/* ignore */
}
try { try {
await this.port.close(); await this.port.close();
} catch (_) { } catch (_) {
@@ -209,41 +311,147 @@
await this.writer.write(data); await this.writer.write(data);
} }
async drainInput(maxMs = 400) { async drainInput(maxMs = 400, clearBuffer = true) {
const deadline = Date.now() + maxMs; const deadline = Date.now() + maxMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const chunk = await this._readStreamChunk(40); const chunk = await this._readStreamChunk(40);
if (!chunk || !chunk.length) break; 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). */ /** Match mpremote SerialTransport.enter_raw_repl(soft_reset). */
async enterRawRepl(softReset = true) { async enterRawRepl(softReset = true) {
log('enterRawRepl', { softReset }); log('enterRawRepl', { softReset });
await this.writeBytes(new Uint8Array([0x0d, 0x03])); const bannerBytes = new TextEncoder().encode(RAW_REPL_BANNER);
await this.drainInput(400); const bannerPromptBytes = new TextEncoder().encode(RAW_REPL_BANNER + '>');
await this.writeBytes(new Uint8Array([0x0d, 0x01])); const softRebootBytes = SOFT_REBOOT_MARKERS.map((m) => new TextEncoder().encode(m));
if (softReset) { if (
log('enterRawRepl: wait banner+>, soft reset'); !bufferHasMicroPython(this._rxBuf) &&
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER + '>'), 15000); !bufferEndsWithFriendlyRepl(this._rxBuf) &&
await this.writeBytes(new Uint8Array([0x04])); 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 { try {
await this.readUntil(new TextEncoder().encode(SOFT_REBOOT), 15000); await this.sendCtrlA();
log('enterRawRepl: saw soft reboot'); await this.waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, 20000);
break;
} catch (e) { } 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 { if (softReset) {
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 45000); log('enterRawRepl: soft reset');
} catch (e) { await this.writeBytes(new Uint8Array([0x04]));
log('enterRawRepl: retry ctrl-A after', e.message); try {
await this.writeBytes(new Uint8Array([0x0d, 0x01])); await this.readUntilAny(softRebootBytes, 20000);
await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER), 30000); 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; this.inRawRepl = true;
log('enterRawRepl: ready'); log('enterRawRepl: ready');
} }
@@ -275,9 +483,34 @@
await this.readUntil(new Uint8Array([0x04]), 30000); 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) { async execRawNoFollow(commandBytes) {
log('execRawNoFollow', commandBytes.length, 'bytes'); log('execRawNoFollow', commandBytes.length, 'bytes');
await this.readUntil(new TextEncoder().encode('>'), 10000); await this.awaitRawReplExecPrompt(60000);
if (this.useRawPaste) { if (this.useRawPaste) {
await this.writeBytes(new Uint8Array([0x05, 0x41, 0x01])); 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 = { global.LedToolWebSerial = {
supported: () => typeof navigator !== 'undefined' && 'serial' in navigator, supported: serialSupported,
requestPort: () => navigator.serial.requestPort(), getGrantedPorts,
requestPort,
MicroPythonRawRepl, MicroPythonRawRepl,
_test: { bytesIndexOf, decodeText },
}; };
})(typeof window !== 'undefined' ? window : globalThis); })(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,69 @@
/**
* Node regression tests for Web Serial readUntil buffer handling.
* Run: node led-tool/static/web_serial_readuntil_test.mjs
*/
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 readUntilConsume(rxBuf, suffixBytes) {
const idx = bytesIndexOf(rxBuf, suffixBytes);
if (idx < 0) return null;
const end = idx + suffixBytes.length;
const matched = rxBuf.slice(0, end);
rxBuf.splice(0, end);
return matched;
}
function enc(s) {
return [...new TextEncoder().encode(s)];
}
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
const RAW = 'raw REPL; CTRL-B to exit\r\n';
const SOFT = 'soft reboot\r\n';
// Old bug: one chunk with soft reboot + banner; suffix-at-end + clear-all loses banner.
{
const rx = enc(`MPY: ${SOFT}${RAW}boot.py line\r\n`);
const soft = enc(SOFT);
const m1 = readUntilConsume(rx, soft);
assert(m1 !== null, 'soft reboot should match');
const banner = enc(RAW);
const m2 = readUntilConsume(rx, banner);
assert(m2 !== null, 'banner must remain in buffer after soft reboot match');
assert(rx.length > 0, 'boot.py tail should remain after banner');
}
// Banner anywhere in buffer, not only at end.
{
const rx = enc(`noise${RAW}>>> `);
const banner = enc(RAW);
const m = readUntilConsume(rx, banner);
assert(m !== null, 'banner should match mid-buffer');
}
// MPY: soft reboot marker
{
const rx = enc(`MPY: ${SOFT}`);
const soft = enc('MPY: soft reboot\r\n');
assert(readUntilConsume(rx, soft) !== null, 'MPY soft reboot marker');
}
console.log('web_serial_readuntil_test: ok');