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:
19
README.md
19
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.
|
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://<host>: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
|
## License
|
||||||
|
|
||||||
See **LICENSE** in this directory.
|
See **LICENSE** in this directory.
|
||||||
|
|||||||
25
host_ports.py
Normal file
25
host_ports.py
Normal file
@@ -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)]
|
||||||
209
static/settings_editor.html
Normal file
209
static/settings_editor.html
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>LED device settings</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--text: #e5e7eb;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--border: #374151;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #0b1020;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.15rem; margin: 0 0 0.35rem; }
|
||||||
|
.muted { color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem; }
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--muted); }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #1f2937;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.row > * { flex: 1; min-width: 10rem; }
|
||||||
|
.btn {
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #4b5563;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||||
|
gap: 0 0.5rem;
|
||||||
|
}
|
||||||
|
#status { font-size: 0.85rem; margin-top: 0.5rem; }
|
||||||
|
#status.error { color: var(--danger); }
|
||||||
|
.flash {
|
||||||
|
display: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: #14532d;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.flash.error { background: #5e1b1b; color: #fca5a5; }
|
||||||
|
.flash.show { display: block; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-api-base="/led-tool">
|
||||||
|
<h1>Device settings (USB)</h1>
|
||||||
|
<p class="muted">Edit <code>settings.json</code> via <strong>Web Serial</strong> (USB on this computer) or <strong>host serial</strong> (Pi running <code>led-cli</code>). Use <strong>Connect</strong> for your transport, then <strong>Download</strong> or <strong>Upload</strong>.</p>
|
||||||
|
<div id="flash" class="flash"></div>
|
||||||
|
|
||||||
|
<div class="card" id="webserial-wrap" hidden>
|
||||||
|
<h2 style="font-size:1rem;margin:0 0 0.5rem;">Web Serial</h2>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="btn btn-primary" id="webserial-connect">Connect USB</button>
|
||||||
|
<button type="button" class="btn" id="webserial-disconnect" hidden>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="host-serial-wrap">
|
||||||
|
<h2 style="font-size:1rem;margin:0 0 0.5rem;">Host serial</h2>
|
||||||
|
<label for="host-port">Port</label>
|
||||||
|
<select id="host-port"><option value="">Select port</option></select>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="btn btn-primary" id="host-connect">Connect</button>
|
||||||
|
<button type="button" class="btn" id="host-disconnect" hidden>Disconnect</button>
|
||||||
|
<button type="button" class="btn" id="btn-refresh-ports">Refresh ports</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="margin-bottom:0.75rem;">
|
||||||
|
<button type="button" class="btn btn-primary" id="btn-download">Download</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="btn-upload">Upload & reset</button>
|
||||||
|
<button type="button" class="btn" id="btn-from-json">JSON → form</button>
|
||||||
|
<button type="button" class="btn" id="btn-to-json">Form → JSON</button>
|
||||||
|
</div>
|
||||||
|
<p id="status">Ready</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" data-setting="name" type="text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="num_leds">Num LEDs</label>
|
||||||
|
<input id="num_leds" data-setting="num_leds" type="number" min="1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="led_pin">LED pin</label>
|
||||||
|
<input id="led_pin" data-setting="led_pin" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="brightness">Brightness</label>
|
||||||
|
<input id="brightness" data-setting="brightness" type="number" min="0" max="255" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="color_order">Colour order</label>
|
||||||
|
<select id="color_order" data-setting="color_order">
|
||||||
|
<option value=""></option>
|
||||||
|
<option value="rgb">rgb</option>
|
||||||
|
<option value="rbg">rbg</option>
|
||||||
|
<option value="grb">grb</option>
|
||||||
|
<option value="gbr">gbr</option>
|
||||||
|
<option value="brg">brg</option>
|
||||||
|
<option value="bgr">bgr</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="transport_type">Transport</label>
|
||||||
|
<select id="transport_type" data-setting="transport_type">
|
||||||
|
<option value=""></option>
|
||||||
|
<option value="espnow">espnow</option>
|
||||||
|
<option value="wifi">wifi</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ssid">WiFi SSID</label>
|
||||||
|
<input id="ssid" data-setting="ssid" type="text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">WiFi password</label>
|
||||||
|
<input id="password" data-setting="password" type="password" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="wifi_channel">WiFi channel</label>
|
||||||
|
<input id="wifi_channel" data-setting="wifi_channel" type="number" min="1" max="11" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="default">Default preset</label>
|
||||||
|
<input id="default" data-setting="default" type="text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="id">Device id (ESP-NOW)</label>
|
||||||
|
<input id="id" data-setting="id" type="number" min="0" max="255" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="debug">Debug</label>
|
||||||
|
<select id="debug" data-setting="debug">
|
||||||
|
<option value=""></option>
|
||||||
|
<option value="False">False</option>
|
||||||
|
<option value="True">True</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<label for="raw_json">Raw settings.json</label>
|
||||||
|
<textarea id="raw_json" rows="14" spellcheck="false">{}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const api =
|
||||||
|
document.body.getAttribute('data-api-base') ??
|
||||||
|
(location.pathname.includes('/led-tool') ? '/led-tool' : '');
|
||||||
|
const prefix = api + '/static';
|
||||||
|
function loadScript(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = url;
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => reject(new Error('Failed to load ' + url));
|
||||||
|
document.body.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadScript(prefix + '/web_serial.js')
|
||||||
|
.then(() => loadScript(prefix + '/settings_editor.js'))
|
||||||
|
.catch((e) => {
|
||||||
|
const el = document.getElementById('flash');
|
||||||
|
if (el) {
|
||||||
|
el.textContent = e.message;
|
||||||
|
el.className = 'flash error show';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
380
static/settings_editor.js
Normal file
380
static/settings_editor.js
Normal file
@@ -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 = '<option value="">Select port</option>';
|
||||||
|
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();
|
||||||
|
})();
|
||||||
396
static/web_serial.js
Normal file
396
static/web_serial.js
Normal 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);
|
||||||
114
web.py
114
web.py
@@ -19,6 +19,7 @@ from flask import (
|
|||||||
redirect,
|
redirect,
|
||||||
url_for,
|
url_for,
|
||||||
flash,
|
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/<path:filename>")
|
||||||
|
def settings_static(filename):
|
||||||
|
return send_from_directory(_static_dir(), filename)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# Bind to all interfaces so you can reach it from your LAN:
|
# Bind to all interfaces so you can reach it from your LAN:
|
||||||
# python web_app.py
|
# python web.py
|
||||||
# Then open: http://<pi-ip>:5000/
|
# Then open: http://<pi-ip>:5000/editor
|
||||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user