Files
led-tool/static/settings_editor.js
Jimmy bd4d2060ae fix(led-tool): harden Web Serial raw REPL connect
Improve boot wait, readUntil buffer handling, and settings editor
host/Web Serial flows after device reset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:08 +12:00

422 lines
13 KiB
JavaScript

(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;
let webConnectBusy = false;
const webSerialOk = window.LedToolWebSerial && window.LedToolWebSerial.supported();
let webSerialGrantedCount = 0;
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 errorMessage(e) {
if (!e) return 'Unknown error';
if (typeof e === 'string') return e;
return e.message || String(e);
}
function onWebSerialTransferError(e) {
if (webClient) webClient.inRawRepl = false;
return errorMessage(e);
}
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) {
if (webSerialGrantedCount === 1) {
setStatus('Connect USB (one remembered port) or host serial, then Download or Upload.');
} else {
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 (webConnectBusy) return;
if (!window.LedToolWebSerial || !window.LedToolWebSerial.supported()) {
flash('Web Serial not supported (use Chrome or Edge over HTTPS or localhost).', true);
return;
}
if (usingWebSerial()) {
flash('USB already connected.', false);
return;
}
if (usingHostSerial()) {
disconnectHostSerial();
}
webConnectBusy = true;
try {
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. Wait for boot, then Download.', false);
} finally {
webConnectBusy = 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);
const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
if (rawJson) rawJson.value = msg;
flash(msg, true);
setStatus(msg, 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);
const msg = usingWebSerial() ? onWebSerialTransferError(e) : errorMessage(e);
flash(msg, true);
setStatus(msg, 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();
}
});
async function refreshWebSerialGrantHint() {
if (!webSerialOk || !window.LedToolWebSerial.getGrantedPorts) return;
try {
const ports = await window.LedToolWebSerial.getGrantedPorts();
webSerialGrantedCount = ports.length;
updateUi();
} catch (_) {
/* ignore */
}
}
log('settings editor ready', { apiBase, webSerialOk });
updateUi();
loadHostPorts();
refreshWebSerialGrantHint();
})();