Improve boot wait, readUntil buffer handling, and settings editor host/Web Serial flows after device reset. Co-authored-by: Cursor <cursoragent@cursor.com>
654 lines
22 KiB
JavaScript
654 lines
22 KiB
JavaScript
/**
|
|
* 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') : '<bytes>';
|
|
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);
|