Add static editor, host_ports filtering, Flask /editor and REST APIs for ports and led-cli read/write; document standalone and embedded use. Co-authored-by: Cursor <cursoragent@cursor.com>
397 lines
12 KiB
JavaScript
397 lines
12 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 = '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') : '<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);
|
|
}
|
|
}
|
|
|
|
global.LedToolWebSerial = {
|
|
supported: () => typeof navigator !== 'undefined' && 'serial' in navigator,
|
|
requestPort: () => navigator.serial.requestPort(),
|
|
MicroPythonRawRepl,
|
|
};
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|