/** * MicroPython raw REPL over Web Serial (mpremote-compatible). */ (function (global) { const RAW_REPL_BANNER = 'raw REPL; CTRL-B to exit\r\n'; const SOFT_REBOOT_MARKERS = ['soft reboot\r\n', 'MPY: soft reboot\r\n']; const LOG = '[led-tool/serial]'; function log(...args) { console.log(LOG, ...args); } function logDebug(...args) { console.debug(LOG, ...args); } function describeBytes(data) { if (!data || !data.length) return '(empty)'; const u8 = data instanceof Uint8Array ? data : new Uint8Array(data); if (u8.length <= 64) { return Array.from(u8) .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : `\\x${b.toString(16).padStart(2, '0')}`)) .join(''); } return `${u8.length} bytes: ${decodeText(u8.slice(0, 80))}…`; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** 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 */ } } 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) { return new TextDecoder('utf-8', { fatal: false }).decode(u8); } function extractJsonObject(text) { const t = (text || '').trim(); const start = t.indexOf('{'); const end = t.lastIndexOf('}'); if (start === -1 || end === -1 || end < start) { throw new Error(`No JSON object in device output: ${t.slice(0, 400)}`); } return JSON.parse(t.slice(start, end + 1)); } class MicroPythonRawRepl { constructor() { this.port = null; this.reader = null; this.writer = null; this.portOpen = false; this.inRawRepl = false; this._rxBuf = []; this.useRawPaste = true; this._replLock = null; } get connected() { return Boolean(this.portOpen && this.writer); } _appendRx(chunk) { if (!chunk || !chunk.length) return; logDebug('rx chunk', chunk.length, describeBytes(chunk)); for (let i = 0; i < chunk.length; i += 1) this._rxBuf.push(chunk[i]); } async _readStreamChunk(timeoutMs) { if (!this.reader) return null; const result = await Promise.race([ this.reader.read(), sleep(timeoutMs).then(() => ({ timedOut: true })), ]); if (result.timedOut) return null; if (result.done) return 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) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { 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; } 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))); const err = `Timed out waiting for ${JSON.stringify(decodeText(suffixBytes))} (tail: ${JSON.stringify(tail)})`; log('readUntil timeout', 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) { const deadline = Date.now() + timeoutMs; while (this._rxBuf.length < n && Date.now() < deadline) { 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); } if (this._rxBuf.length < n) { throw new Error(`Expected ${n} bytes, got ${this._rxBuf.length}`); } return Uint8Array.from(this._rxBuf.splice(0, n)); } async connect(serialPort) { log('connect: opening port'); await this.disconnect(); this.port = serialPort; this._rxBuf = []; await this.port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none', bufferSize: 65536, }); 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.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) { await this._replLock; return; } log('ensureRawRepl: entering raw REPL'); // 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; } async disconnect() { log('disconnect'); this._replLock = null; const wasRaw = this.inRawRepl; this.inRawRepl = false; this.portOpen = false; this.useRawPaste = true; if (this.writer && wasRaw) { try { await this.writer.write(new Uint8Array([0x0d, 0x02])); } catch (_) { /* ignore */ } } if (this.reader) { try { await this.reader.cancel(); this.reader.releaseLock(); } catch (_) { /* ignore */ } this.reader = null; } if (this.writer) { try { await this.writer.close(); this.writer.releaseLock(); } catch (_) { /* ignore */ } this.writer = null; } if (this.port) { try { await deassertDtrRts(this.port); } catch (_) { /* ignore */ } try { await this.port.close(); } catch (_) { /* ignore */ } this.port = null; } this._rxBuf = []; } async writeBytes(data) { if (!this.writer) throw new Error('Serial not connected'); logDebug('tx', describeBytes(data)); await this.writer.write(data); } 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); } 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 }); 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 ( !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.sendCtrlA(); await this.waitRawReplAfterSoftReboot(bannerBytes, bannerPromptBytes, 20000); break; } catch (e) { if (ctrlARetries >= maxCtrlARetries) throw e; ctrlARetries += 1; log('enterRawRepl: retry ctrl-A', ctrlARetries, e.message); this._rxBuf.length = 0; } } 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'); } async rawPasteWrite(commandBytes) { await this.writeBytes(new Uint8Array([0x05, 0x41, 0x01])); const hdr = await this.readExact(2, 8000); const windowSize = hdr[0] | (hdr[1] << 8); let windowRemain = windowSize; let i = 0; while (i < commandBytes.length) { while (windowRemain === 0) { const b = await this.readExact(1, 15000); if (b[0] === 0x01) { windowRemain += windowSize; } else if (b[0] === 0x04) { await this.writeBytes(new Uint8Array([0x04])); return; } else { throw new Error(`Unexpected byte during raw paste: 0x${b[0].toString(16)}`); } } const n = Math.min(windowRemain, commandBytes.length - i); await this.writeBytes(commandBytes.subarray(i, i + n)); windowRemain -= n; i += n; } await this.writeBytes(new Uint8Array([0x04])); 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.awaitRawReplExecPrompt(60000); if (this.useRawPaste) { await this.writeBytes(new Uint8Array([0x05, 0x41, 0x01])); let data; try { data = await this.readExact(2, 3000); } catch (_) { this.useRawPaste = false; data = null; } if (data) { if (data[0] === 0x52 && data[1] === 0x01) { log('exec: using raw paste mode'); await this.rawPasteWrite(commandBytes); const ok = await this.readExact(2, 15000); if (ok[0] !== 0x4f || ok[1] !== 0x4b) { throw new Error(`Device did not return OK after paste (got ${decodeText(ok)})`); } log('exec: paste OK'); return; } if (data[0] === 0x52 && data[1] === 0x00) { log('exec: raw paste not supported (R\\x00)'); this.useRawPaste = false; } else { log('exec: raw paste probe unexpected', describeBytes(data)); this.useRawPaste = false; try { await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER + '>'), 5000); } catch (_) { /* ignore */ } } } } for (let i = 0; i < commandBytes.length; i += 256) { await this.writeBytes(commandBytes.subarray(i, Math.min(i + 256, commandBytes.length))); await sleep(10); } log('exec: standard raw REPL write'); await this.writeBytes(new Uint8Array([0x04])); const ok = await this.readExact(2, 15000); if (ok[0] !== 0x4f || ok[1] !== 0x4b) { throw new Error(`Device did not return OK (got ${decodeText(ok)})`); } log('exec: got OK'); } async follow(timeoutMs = 30000) { const out1 = await this.readUntil(new Uint8Array([0x04]), timeoutMs); const stdout = decodeText(out1.slice(0, -1)); const out2 = await this.readUntil(new Uint8Array([0x04]), timeoutMs); const stderr = decodeText(out2.slice(0, -1)); log('follow stdout', stdout.slice(0, 500)); if (stderr) log('follow stderr', stderr.slice(0, 500)); return { stdout, stderr }; } async exec(command, timeoutMs = 30000) { const preview = typeof command === 'string' ? command.split('\n').slice(0, 3).join('\n') : ''; log('exec', preview); await this.ensureRawRepl(); const commandBytes = typeof command === 'string' ? new TextEncoder().encode(command) : command; await this.execRawNoFollow(commandBytes); return this.follow(timeoutMs); } async readSettingsJson() { log('readSettingsJson'); const code = [ 'import json', 'try:', " f=open('settings.json','r')", ' d=json.load(f)', ' f.close()', 'except Exception:', ' d={}', 'print(json.dumps(d))', ].join('\n'); const { stdout, stderr } = await this.exec(code); const text = (stdout || '').trim() || (stderr || '').trim(); if (!text) { log('readSettingsJson: empty response'); return {}; } const parsed = extractJsonObject(text); log('readSettingsJson: ok', Object.keys(parsed)); return parsed; } async writeSettingsJson(settings) { log('writeSettingsJson', settings); const jsonText = JSON.stringify(settings); const escaped = JSON.stringify(jsonText); const code = [ 'import json', `s=json.loads(${escaped})`, "f=open('settings.json','w')", 'f.write(s)', 'f.close()', 'import machine', 'machine.reset()', ].join('\n'); await this.exec(code, 60000); } } 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: serialSupported, getGrantedPorts, requestPort, MicroPythonRawRepl, _test: { bytesIndexOf, decodeText }, }; })(typeof window !== 'undefined' ? window : globalThis);