/** * 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 = '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)); } 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; } return true; } 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; } 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; 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 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, }); if (this.port.setSignals) { try { await this.port.setSignals({ dataTerminalReady: false, requestToSend: false }); } catch (_) { /* ignore */ } } this.reader = this.port.readable.getReader(); this.writer = this.port.writable.getWriter(); this.portOpen = true; await sleep(100); await this.drainInput(300); log('connect: port open (raw REPL not entered yet)'); } async ensureRawRepl() { if (this.inRawRepl) return; if (this._replLock) { await this._replLock; return; } log('ensureRawRepl: entering raw REPL'); this._replLock = this.enterRawRepl(true).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 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) { const deadline = Date.now() + maxMs; while (Date.now() < deadline) { const chunk = await this._readStreamChunk(40); if (!chunk || !chunk.length) break; } this._rxBuf.length = 0; } /** 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])); if (softReset) { log('enterRawRepl: wait banner+>, soft reset'); await this.readUntil(new TextEncoder().encode(RAW_REPL_BANNER + '>'), 15000); await this.writeBytes(new Uint8Array([0x04])); try { await this.readUntil(new TextEncoder().encode(SOFT_REBOOT), 15000); log('enterRawRepl: saw soft reboot'); } catch (e) { log('enterRawRepl: no soft reboot banner', e.message); } } 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); } 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 execRawNoFollow(commandBytes) { log('execRawNoFollow', commandBytes.length, 'bytes'); await this.readUntil(new TextEncoder().encode('>'), 10000); 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); } } global.LedToolWebSerial = { supported: () => typeof navigator !== 'undefined' && 'serial' in navigator, requestPort: () => navigator.serial.requestPort(), MicroPythonRawRepl, }; })(typeof window !== 'undefined' ? window : globalThis);