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`, …) |
| `--preset` / `--pattern` | Create or replace a named preset in **led-driver** `presets.json` |
| `--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 |
| `-r`, `--reset` | Reset the device |
| `-f`, `--follow` | Follow serial output (optional timeout seconds) |
| `--pause` | Sleep N seconds (for chained actions) |
| `-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` |
| `-e`, `--erase` | Erase everything at device root (including `settings.json` and `presets.json`) |
| `--rm` | Remove a path on the device |
@@ -51,7 +52,7 @@ python web.py
# 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.

25
cli.py
View File

@@ -150,7 +150,8 @@ _FLAGS_WITH_VALUE = frozenset({
'-p', '--port', '-n', '--name', '--pin', '-b', '--brightness',
'-l', '--leds', '-d', '-debug', '--debug', '-o', '--order',
'--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)
%(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",
)
parser.add_argument(
"--serial-baudrate",
type=int,
metavar="BAUD",
help="bridge: UART1 baud for Pi serial link (default 921600)",
)
parser.add_argument(
"--ssid",
help="led-driver ssid (Wi-Fi network in wifi mode)",
@@ -747,6 +758,18 @@ Examples:
if args.transport is not None:
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:
edits["ssid"] = args.ssid

View File

@@ -155,6 +155,14 @@
<label for="wifi_channel">WiFi channel</label>
<input id="wifi_channel" data-setting="wifi_channel" type="number" min="1" max="11" />
</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>
<label for="default">Default preset</label>
<input id="default" data-setting="default" type="text" />
@@ -185,10 +193,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);

View File

@@ -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();
})();

View File

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

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