feat(led-tool): browser settings editor with Web Serial

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>
This commit is contained in:
2026-05-18 14:54:15 +12:00
parent 1edcb8b1f7
commit f74e21f206
6 changed files with 1141 additions and 2 deletions

396
static/web_serial.js Normal file
View File

@@ -0,0 +1,396 @@
/**
* 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);