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:
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user