From f74e21f2068cd55ed4ba66f0be7d7aa71c3d0742 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Mon, 18 May 2026 14:54:15 +1200 Subject: [PATCH] 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 --- README.md | 19 ++ host_ports.py | 25 +++ static/settings_editor.html | 209 +++++++++++++++++++ static/settings_editor.js | 380 ++++++++++++++++++++++++++++++++++ static/web_serial.js | 396 ++++++++++++++++++++++++++++++++++++ web.py | 114 ++++++++++- 6 files changed, 1141 insertions(+), 2 deletions(-) create mode 100644 host_ports.py create mode 100644 static/settings_editor.html create mode 100644 static/settings_editor.js create mode 100644 static/web_serial.js diff --git a/README.md b/README.md index 41336f5..1f22a21 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,25 @@ Connection is always via **`-p` / `--port`** (default `/dev/ttyACM0`). There is Run **`python cli.py -h`** for the full epilog and argument list. +## Web UI (`static/` + `web.py`) + +Edit **`settings.json`** in the browser: + +- **Web Serial** — USB on the machine running the browser (Chrome/Edge). Use **Connect USB**, then **Download** / **Upload**. +- **Host serial** — USB on the Pi/PC running **`led-cli`** (port list + **`led-cli`** merge/upload). + +**Standalone (Flask):** + +```bash +cd led-tool +python web.py +# open http://:5000/editor +``` + +**Embedded in led-controller:** open **LED Tool** in the main UI, or visit **`/led-tool/editor`**. + +Legacy Flask form UI remains at **`/`** on port 5000; prefer **`/editor`** for Web Serial support. + ## License See **LICENSE** in this directory. diff --git a/host_ports.py b/host_ports.py new file mode 100644 index 0000000..6eb3993 --- /dev/null +++ b/host_ports.py @@ -0,0 +1,25 @@ +"""Host serial port list filtering for led-tool /ports API.""" +import re + +# Exclude /dev/ttyS2 and higher (keep ttyS0, ttyS1; keep ttyACM*, ttyUSB*, etc.) +_TTYS_HIGH = re.compile(r"^/dev/ttyS([2-9]|[1-9]\d+)$") + + +def include_host_serial_device(device: str) -> bool: + return not _TTYS_HIGH.match(device or "") + + +def _is_na(value: str) -> bool: + return (value or "").strip().lower() in ("n/a", "na") + + +def include_host_serial_port(port: dict) -> bool: + if not include_host_serial_device(port.get("device", "")): + return False + if _is_na(port.get("description")) or _is_na(port.get("hwid")): + return False + return True + + +def filter_port_dicts(ports: list) -> list: + return [p for p in ports if include_host_serial_port(p)] diff --git a/static/settings_editor.html b/static/settings_editor.html new file mode 100644 index 0000000..c714c27 --- /dev/null +++ b/static/settings_editor.html @@ -0,0 +1,209 @@ + + + + + LED device settings + + + + +

Device settings (USB)

+

Edit settings.json via Web Serial (USB on this computer) or host serial (Pi running led-cli). Use Connect for your transport, then Download or Upload.

+
+ + + +
+

Host serial

+ + +
+ + + +
+
+ +
+
+ + + + +
+

Ready

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ + + + diff --git a/static/settings_editor.js b/static/settings_editor.js new file mode 100644 index 0000000..a354ac1 --- /dev/null +++ b/static/settings_editor.js @@ -0,0 +1,380 @@ +(function () { + const LOG = '[led-tool/editor]'; + const log = (...args) => console.log(LOG, ...args); + + const apiBase = + (document.body && document.body.dataset.apiBase) || + (window.location.pathname.includes('/led-tool') ? '/led-tool' : ''); + const statusEl = document.getElementById('status'); + const messageEl = document.getElementById('flash'); + const portSelect = document.getElementById('host-port'); + const rawJson = document.getElementById('raw_json'); + const hostWrap = document.getElementById('host-serial-wrap'); + const webWrap = document.getElementById('webserial-wrap'); + const connectBtn = document.getElementById('webserial-connect'); + const disconnectBtn = document.getElementById('webserial-disconnect'); + const hostConnectBtn = document.getElementById('host-connect'); + const hostDisconnectBtn = document.getElementById('host-disconnect'); + const refreshPortsBtn = document.getElementById('btn-refresh-ports'); + + const DEFAULT_HOST_PORT = '/dev/ttyACM0'; + + let webClient = null; + let webPort = null; + let hostConnected = false; + let hostConnectedPort = ''; + let transferBusy = false; + + const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported(); + + function setStatus(text, isError) { + if (!statusEl) return; + statusEl.textContent = text; + statusEl.classList.toggle('error', Boolean(isError)); + } + + function flash(text, isError) { + if (!messageEl) return; + messageEl.textContent = text; + messageEl.className = isError ? 'flash error show' : 'flash show'; + setTimeout(() => messageEl.classList.remove('show'), 5000); + } + + function formToObject() { + const out = {}; + document.querySelectorAll('[data-setting]').forEach((el) => { + const key = el.getAttribute('data-setting'); + if (!key) return; + const raw = (el.value || '').trim(); + if (raw === '') return; + if (el.type === 'number') { + const n = parseInt(raw, 10); + if (!Number.isNaN(n)) out[key] = n; + } else if (el.tagName === 'SELECT' && el.id === 'debug') { + out[key] = raw === 'True'; + } else { + out[key] = raw; + } + }); + return out; + } + + function objectToForm(settings) { + if (!settings || typeof settings !== 'object') return; + document.querySelectorAll('[data-setting]').forEach((el) => { + const key = el.getAttribute('data-setting'); + if (!key || !Object.prototype.hasOwnProperty.call(settings, key)) return; + const v = settings[key]; + if (el.id === 'debug') { + el.value = v === true || v === 'True' ? 'True' : 'False'; + } else { + el.value = v === undefined || v === null ? '' : String(v); + } + }); + if (rawJson) rawJson.value = JSON.stringify(settings, null, 2); + } + + function usingWebSerial() { + return webClient && webClient.connected; + } + + function usingHostSerial() { + return hostConnected && Boolean(hostConnectedPort); + } + + function updateUi() { + const webOpen = usingWebSerial(); + const hostOpen = usingHostSerial(); + if (webWrap) webWrap.hidden = !webSerialOk || hostOpen; + if (connectBtn) connectBtn.hidden = webOpen || hostOpen || !webSerialOk; + if (disconnectBtn) disconnectBtn.hidden = !webOpen; + if (hostWrap) hostWrap.hidden = webOpen; + const hostFieldsLocked = webOpen || hostOpen; + if (portSelect) portSelect.disabled = hostFieldsLocked; + if (refreshPortsBtn) refreshPortsBtn.disabled = hostFieldsLocked; + if (hostConnectBtn) hostConnectBtn.hidden = hostOpen || webOpen; + if (hostDisconnectBtn) hostDisconnectBtn.hidden = !hostOpen || webOpen; + if (webOpen) { + setStatus('USB open (device not reset until Download/Upload).'); + } else if (hostOpen) { + setStatus(`Host serial: ${hostConnectedPort} (use Download or Upload).`); + } else if (webSerialOk) { + setStatus('Connect host serial or Web Serial USB, then Download or Upload.'); + } else { + setStatus(`Connect host serial (default ${DEFAULT_HOST_PORT}).`); + } + } + + async function loadHostPorts() { + if (!portSelect) return; + try { + const res = await fetch(`${apiBase}/ports`); + const data = await res.json(); + const prev = portSelect.value; + portSelect.innerHTML = ''; + for (const p of data.ports || []) { + const opt = document.createElement('option'); + opt.value = p.device; + opt.textContent = `${p.device} - ${p.description || 'device'}`; + portSelect.appendChild(opt); + } + const acm0 = DEFAULT_HOST_PORT; + const hasAcm0 = [...portSelect.options].some((o) => o.value === acm0); + if (hasAcm0) { + portSelect.value = acm0; + } else if (prev && [...portSelect.options].some((o) => o.value === prev)) { + portSelect.value = prev; + } + } catch (e) { + flash(`Could not list host ports: ${e.message}`, true); + } + } + + function selectedHostPort() { + return portSelect && portSelect.value ? portSelect.value.trim() : ''; + } + + function hostPort() { + if (hostConnected && hostConnectedPort) return hostConnectedPort; + return selectedHostPort(); + } + + function connectHostSerial() { + if (usingWebSerial()) { + disconnectWebSerial(); + } + const port = selectedHostPort(); + if (!port) { + flash('Select a host serial port.', true); + return; + } + hostConnectedPort = port; + hostConnected = true; + log('connectHostSerial', port); + updateUi(); + flash(`Host serial ready: ${port}`, false); + } + + function disconnectHostSerial() { + hostConnected = false; + hostConnectedPort = ''; + updateUi(); + } + + async function downloadHost() { + const port = hostConnectedPort || hostPort(); + if (!port) { + flash('Connect host serial first.', true); + return; + } + log('downloadHost', port); + setStatus('Downloading from device (host serial)...'); + const res = await fetch(`${apiBase}/settings?port=${encodeURIComponent(port)}`); + const data = await res.json(); + log('downloadHost response', { ok: res.ok, status: res.status, data }); + if (!res.ok) { + throw new Error(data.error || 'Download failed'); + } + if (!data.settings) { + throw new Error('No settings in response (check CLI output)'); + } + objectToForm(data.settings); + flash('Settings downloaded.', false); + setStatus(`Downloaded from ${port}`); + } + + async function uploadHost() { + const port = hostConnectedPort || hostPort(); + if (!port) { + flash('Connect host serial first.', true); + return; + } + const payload = { port, ...formToObject() }; + log('uploadHost', payload); + setStatus('Uploading via host serial (led-cli)...'); + const res = await fetch(`${apiBase}/settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Upload failed'); + if (!data.ok) throw new Error(data.stderr || 'led-cli failed'); + flash('Settings uploaded; device resetting.', false); + setStatus('Upload complete.'); + } + + async function connectWebSerial() { + if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) { + flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true); + return; + } + if (usingHostSerial()) { + disconnectHostSerial(); + } + log('connectWebSerial: requestPort'); + setStatus('Opening USB port…'); + webPort = await window.LedToolWebSerial.requestPort(); + log('connectWebSerial: port selected', webPort); + webClient = new window.LedToolWebSerial.MicroPythonRawRepl(); + await webClient.connect(webPort); + updateUi(); + flash('USB port open. Use Download or Upload to talk to the device.', false); + } + + async function disconnectWebSerial() { + if (webClient) { + try { + await webClient.disconnect(); + } catch (_) { + /* ignore */ + } + } + webClient = null; + webPort = null; + updateUi(); + } + + async function downloadWebSerial() { + log('downloadWebSerial'); + setStatus('Reading settings from device (Web Serial)…'); + const settings = await webClient.readSettingsJson(); + log('downloadWebSerial result', settings); + objectToForm(settings); + if (rawJson) rawJson.value = JSON.stringify(settings, null, 2); + flash('Settings read over Web Serial.', false); + setStatus('Downloaded via Web Serial.'); + } + + async function uploadWebSerial() { + log('uploadWebSerial'); + let settings = {}; + if (rawJson && rawJson.value.trim()) { + try { + settings = JSON.parse(rawJson.value); + } catch (e) { + flash('Invalid JSON in raw panel.', true); + return; + } + } else { + settings = await webClient.readSettingsJson(); + Object.assign(settings, formToObject()); + } + await webClient.writeSettingsJson(settings); + flash('Uploaded via Web Serial; device resetting.', false); + setStatus('Upload complete.'); + await disconnectWebSerial(); + } + + document.getElementById('btn-download')?.addEventListener('click', async () => { + if (transferBusy) return; + transferBusy = true; + try { + if (usingWebSerial()) { + await downloadWebSerial(); + } else if (usingHostSerial()) { + await downloadHost(); + } else { + flash( + webSerialOk + ? 'Connect host serial or Web Serial USB first.' + : 'Click Connect on host serial first.', + true, + ); + } + } catch (e) { + console.error(LOG, 'download failed', e); + if (rawJson) rawJson.value = String(e.message || e); + flash(e.message, true); + setStatus(e.message, true); + } finally { + transferBusy = false; + } + }); + + document.getElementById('btn-upload')?.addEventListener('click', async () => { + if (transferBusy) return; + transferBusy = true; + try { + if (usingWebSerial()) { + await uploadWebSerial(); + } else if (usingHostSerial()) { + await uploadHost(); + } else { + flash( + webSerialOk + ? 'Connect host serial or Web Serial USB first.' + : 'Click Connect on host serial first.', + true, + ); + } + } catch (e) { + console.error(LOG, 'upload failed', e); + flash(e.message, true); + setStatus(e.message, true); + } finally { + transferBusy = false; + } + }); + + document.getElementById('btn-from-json')?.addEventListener('click', () => { + try { + const parsed = JSON.parse(rawJson.value || '{}'); + objectToForm(parsed); + flash('Form updated from JSON.', false); + } catch (e) { + flash('Invalid JSON.', true); + } + }); + + document.getElementById('btn-to-json')?.addEventListener('click', () => { + const merged = formToObject(); + if (rawJson && rawJson.value.trim()) { + try { + Object.assign(merged, JSON.parse(rawJson.value)); + } catch (_) { + /* use form only */ + } + } + rawJson.value = JSON.stringify(merged, null, 2); + flash('JSON updated from form.', false); + }); + + connectBtn?.addEventListener('click', () => { + connectWebSerial().catch((e) => { + disconnectWebSerial(); + const msg = + e.name === 'NotFoundError' + ? 'No USB device selected.' + : e.name === 'SecurityError' + ? 'Web Serial blocked (open LED Tool in a top-level tab, or use host serial on the Pi).' + : e.message || String(e); + flash(msg, true); + setStatus(msg, true); + }); + }); + + disconnectBtn?.addEventListener('click', () => { + disconnectWebSerial(); + flash('Web Serial disconnected.', false); + }); + + refreshPortsBtn?.addEventListener('click', () => loadHostPorts()); + + hostConnectBtn?.addEventListener('click', () => connectHostSerial()); + + hostDisconnectBtn?.addEventListener('click', () => { + disconnectHostSerial(); + flash('Host serial disconnected.', false); + }); + + window.addEventListener('beforeunload', () => { + if (webClient) { + webClient.disconnect(); + } + }); + + log('settings editor ready', { apiBase, webSerialOk }); + updateUi(); + loadHostPorts(); +})(); diff --git a/static/web_serial.js b/static/web_serial.js new file mode 100644 index 0000000..14c1624 --- /dev/null +++ b/static/web_serial.js @@ -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') : ''; + 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); diff --git a/web.py b/web.py index bd77045..85aba5a 100644 --- a/web.py +++ b/web.py @@ -19,6 +19,7 @@ from flask import ( redirect, url_for, flash, + send_from_directory, ) @@ -696,10 +697,119 @@ def handle_action(): ) +def _static_dir() -> str: + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + + +@app.route("/ports") +def api_list_ports(): + from host_ports import filter_port_dicts + from serial.tools import list_ports + + ports = filter_port_dicts( + [ + {"device": i.device, "description": i.description, "hwid": i.hwid} + for i in list_ports.comports() + ] + ) + cli = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cli.py") + return json.dumps({"ports": ports, "led_cli_exists": os.path.exists(cli)}) + + +@app.route("/settings", methods=["GET"]) +def api_read_settings(): + import subprocess + import sys + + port = (request.args.get("port") or "").strip() + if not port: + return json.dumps({"error": "port is required"}), 400 + cli = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cli.py") + result = subprocess.run( + [sys.executable, cli, "--port", port, "--show"], + capture_output=True, + text=True, + timeout=180, + cwd=os.path.dirname(cli), + ) + settings = None + try: + settings = json.loads((result.stdout or "").strip()) + except json.JSONDecodeError: + pass + return json.dumps( + { + "ok": result.returncode == 0, + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "settings": settings, + } + ) + + +@app.route("/settings", methods=["POST"]) +def api_write_settings(): + import subprocess + import sys + + data = request.get_json(silent=True) or {} + port = str(data.get("port") or "").strip() + if not port: + return json.dumps({"error": "port is required"}), 400 + cli = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cli.py") + cmd = [sys.executable, cli, "--port", port] + flag_map = ( + ("name", "--name"), + ("led_pin", "--pin"), + ("num_leds", "--leds"), + ("brightness", "--brightness"), + ("transport", "--transport"), + ("ssid", "--ssid"), + ("password", "--wifi-password"), + ("wifi_channel", "--wifi-channel"), + ("default", "--default"), + ) + for key, flag in flag_map: + val = data.get(key) + if val is None: + continue + s = str(val).strip() + if s: + cmd.extend([flag, s]) + cmd.append("--follow") + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=180, + cwd=os.path.dirname(cli), + ) + return json.dumps( + { + "ok": result.returncode == 0, + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + ) + + +@app.route("/editor") +def settings_editor(): + """Static settings editor (Web Serial + host serial). Prefer this over the legacy form.""" + return send_from_directory(_static_dir(), "settings_editor.html") + + +@app.route("/static/") +def settings_static(filename): + return send_from_directory(_static_dir(), filename) + + def main() -> None: # Bind to all interfaces so you can reach it from your LAN: - # python web_app.py - # Then open: http://:5000/ + # python web.py + # Then open: http://:5000/editor app.run(host="0.0.0.0", port=5000, debug=False)