feat(led-tool): embed settings editor in main UI
Serve led-tool static editor at /led-tool/editor, filter host serial ports, and load the modal via iframe instead of the legacy form. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,20 +3,40 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from microdot import Microdot
|
from microdot import Microdot, send_file
|
||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
|
|
||||||
|
_STATIC_ALLOWED = frozenset(
|
||||||
|
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _repo_root() -> str:
|
def _repo_root() -> str:
|
||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _led_tool_static_dir() -> str:
|
||||||
|
return os.path.join(_repo_root(), "led-tool", "static")
|
||||||
|
|
||||||
|
|
||||||
def _led_cli_path() -> str:
|
def _led_cli_path() -> str:
|
||||||
return os.path.join(_repo_root(), "led-tool", "cli.py")
|
return os.path.join(_repo_root(), "led-tool", "cli.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_host_serial_ports(ports: list) -> list:
|
||||||
|
mod_path = os.path.join(_repo_root(), "led-tool", "host_ports.py")
|
||||||
|
if not os.path.isfile(mod_path):
|
||||||
|
return ports
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("led_tool_host_ports", mod_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod.filter_port_dicts(ports)
|
||||||
|
|
||||||
|
|
||||||
def _build_led_cli_command(port: str, payload: dict):
|
def _build_led_cli_command(port: str, payload: dict):
|
||||||
cmd = [sys.executable, _led_cli_path(), "--port", port]
|
cmd = [sys.executable, _led_cli_path(), "--port", port]
|
||||||
|
|
||||||
@@ -92,17 +112,41 @@ def _extract_settings_from_stdout(stdout: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/editor")
|
||||||
|
async def settings_editor_page(request):
|
||||||
|
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
|
||||||
|
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return send_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/static/<path:filename>")
|
||||||
|
async def led_tool_static(request, filename):
|
||||||
|
if filename not in _STATIC_ALLOWED:
|
||||||
|
return "Not found", 404
|
||||||
|
path = os.path.join(_led_tool_static_dir(), filename)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return "Not found", 404
|
||||||
|
return send_file(path)
|
||||||
|
|
||||||
|
|
||||||
@controller.get("/ports")
|
@controller.get("/ports")
|
||||||
async def list_serial_ports(request):
|
async def list_serial_ports(request):
|
||||||
ports = []
|
ports = _filter_host_serial_ports(
|
||||||
for info in list_ports.comports():
|
[
|
||||||
ports.append(
|
|
||||||
{
|
{
|
||||||
"device": info.device,
|
"device": info.device,
|
||||||
"description": info.description,
|
"description": info.description,
|
||||||
"hwid": info.hwid,
|
"hwid": info.hwid,
|
||||||
}
|
}
|
||||||
)
|
for info in list_ports.comports()
|
||||||
|
]
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,254 +2,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const openBtn = document.getElementById('led-tool-btn');
|
const openBtn = document.getElementById('led-tool-btn');
|
||||||
const modal = document.getElementById('led-tool-modal');
|
const modal = document.getElementById('led-tool-modal');
|
||||||
const closeBtn = document.getElementById('led-tool-close-btn');
|
const closeBtn = document.getElementById('led-tool-close-btn');
|
||||||
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn');
|
const iframe = document.getElementById('led-tool-iframe');
|
||||||
const form = document.getElementById('led-tool-form');
|
|
||||||
const readBtn = document.getElementById('led-tool-read-btn');
|
|
||||||
const resetBtn = document.getElementById('led-tool-reset-btn');
|
|
||||||
const portSelect = document.getElementById('led-tool-port');
|
|
||||||
const outputEl = document.getElementById('led-tool-output');
|
|
||||||
const messageEl = document.getElementById('led-tool-message');
|
|
||||||
|
|
||||||
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) {
|
if (!openBtn || !modal || !iframe) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showMessage = (text, type = 'success') => {
|
|
||||||
messageEl.textContent = text;
|
|
||||||
messageEl.className = `message ${type} show`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setOutput = (text) => {
|
|
||||||
outputEl.value = text || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseApiResponse = async (response) => {
|
|
||||||
const bodyText = await response.text();
|
|
||||||
let data = null;
|
|
||||||
try {
|
|
||||||
data = bodyText ? JSON.parse(bodyText) : {};
|
|
||||||
} catch (error) {
|
|
||||||
data = { error: bodyText || `HTTP ${response.status}` };
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setFieldValue = (id, value) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value === undefined || value === null) return;
|
|
||||||
el.value = String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const populateFormFromSettings = (settings) => {
|
|
||||||
if (!settings || typeof settings !== 'object') return false;
|
|
||||||
setFieldValue('led-tool-name', settings.name);
|
|
||||||
setFieldValue('led-tool-num-leds', settings.num_leds);
|
|
||||||
setFieldValue('led-tool-led-pin', settings.led_pin);
|
|
||||||
setFieldValue('led-tool-brightness', settings.brightness);
|
|
||||||
setFieldValue('led-tool-transport', settings.transport_type);
|
|
||||||
setFieldValue('led-tool-ssid', settings.ssid);
|
|
||||||
setFieldValue('led-tool-password', settings.password);
|
|
||||||
setFieldValue('led-tool-wifi-channel', settings.wifi_channel);
|
|
||||||
setFieldValue('led-tool-default', settings.default);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPorts = async () => {
|
|
||||||
const defaultPort = '/dev/ttyACM0';
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/ports');
|
|
||||||
const data = await response.json();
|
|
||||||
const previous = portSelect.value;
|
|
||||||
portSelect.innerHTML = '<option value="">Select a serial port</option>';
|
|
||||||
|
|
||||||
for (const port of data.ports || []) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = port.device;
|
|
||||||
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
|
|
||||||
portSelect.appendChild(option);
|
|
||||||
}
|
|
||||||
if (previous) {
|
|
||||||
portSelect.value = previous;
|
|
||||||
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
|
|
||||||
portSelect.value = defaultPort;
|
|
||||||
} else {
|
|
||||||
const fallback = document.createElement('option');
|
|
||||||
fallback.value = defaultPort;
|
|
||||||
fallback.textContent = `${defaultPort} - default`;
|
|
||||||
portSelect.appendChild(fallback);
|
|
||||||
portSelect.value = defaultPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.led_cli_exists) {
|
|
||||||
showMessage('led-tool/cli.py was not found on the host.', 'error');
|
|
||||||
} else if ((data.ports || []).length === 0) {
|
|
||||||
showMessage('No serial ports found.', 'error');
|
|
||||||
} else {
|
|
||||||
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
openBtn.addEventListener('click', () => {
|
openBtn.addEventListener('click', () => {
|
||||||
|
iframe.src = '/led-tool/editor';
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
loadPorts();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (closeBtn) {
|
if (closeBtn) {
|
||||||
closeBtn.addEventListener('click', () => {
|
closeBtn.addEventListener('click', () => {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
|
iframe.src = 'about:blank';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refreshPortsBtn) {
|
|
||||||
refreshPortsBtn.addEventListener('click', () => {
|
|
||||||
loadPorts();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (readBtn) {
|
|
||||||
readBtn.addEventListener('click', async () => {
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOutput('Reading settings from device...');
|
|
||||||
showMessage('Reading settings over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/led-tool/settings?port=${encodeURIComponent(port)}`);
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Read failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
const populated = populateFormFromSettings(data.settings);
|
|
||||||
if (populated) {
|
|
||||||
showMessage('Settings read and fields populated.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Settings read successfully.', 'success');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showMessage('Read completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetBtn) {
|
|
||||||
resetBtn.addEventListener('click', async () => {
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOutput('Resetting device and following output...');
|
|
||||||
showMessage('Resetting device over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/reset', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ port }),
|
|
||||||
});
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Reset failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
showMessage('Device reset complete.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Reset completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
port,
|
|
||||||
name: document.getElementById('led-tool-name')?.value?.trim() || '',
|
|
||||||
num_leds: document.getElementById('led-tool-num-leds')?.value?.trim() || '',
|
|
||||||
led_pin: document.getElementById('led-tool-led-pin')?.value?.trim() || '',
|
|
||||||
brightness: document.getElementById('led-tool-brightness')?.value?.trim() || '',
|
|
||||||
transport: document.getElementById('led-tool-transport')?.value?.trim() || '',
|
|
||||||
ssid: document.getElementById('led-tool-ssid')?.value?.trim() || '',
|
|
||||||
password: document.getElementById('led-tool-password')?.value?.trim() || '',
|
|
||||||
wifi_channel: document.getElementById('led-tool-wifi-channel')?.value?.trim() || '',
|
|
||||||
default: document.getElementById('led-tool-default')?.value?.trim() || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
setOutput('Running led-tool command...');
|
|
||||||
showMessage('Running command over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Command failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
showMessage('Settings applied via USB.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Command completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -765,79 +765,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED Tool Modal -->
|
<!-- LED Tool Modal (led-tool/static settings editor) -->
|
||||||
<div id="led-tool-modal" class="modal">
|
<div id="led-tool-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content" style="max-width: 960px; width: 95vw;">
|
||||||
<h2>LED Tool (USB)</h2>
|
<div class="modal-actions" style="margin-bottom: 0.5rem;">
|
||||||
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p>
|
<h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
|
||||||
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div>
|
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
|
||||||
<form id="led-tool-form">
|
</div>
|
||||||
<div class="form-group">
|
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" style="width:100%;height:min(75vh,720px);border:1px solid #4a4a4a;border-radius:4px;background:#0b1020;"></iframe>
|
||||||
<label for="led-tool-port">Serial port</label>
|
|
||||||
<div class="profiles-actions" style="gap: 0.5rem;">
|
|
||||||
<select id="led-tool-port" required style="flex:1;">
|
|
||||||
<option value="">Select a serial port</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-name">Name</label>
|
|
||||||
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-num-leds">Num LEDs</label>
|
|
||||||
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-led-pin">LED pin</label>
|
|
||||||
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-brightness">Brightness</label>
|
|
||||||
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-wifi-channel">WiFi channel</label>
|
|
||||||
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-transport">Transport</label>
|
|
||||||
<select id="led-tool-transport">
|
|
||||||
<option value="">(no change)</option>
|
|
||||||
<option value="espnow">espnow</option>
|
|
||||||
<option value="wifi">wifi</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-default">Default preset</label>
|
|
||||||
<input type="text" id="led-tool-default" placeholder="on">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-ssid">SSID</label>
|
|
||||||
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-password">WiFi password</label>
|
|
||||||
<input type="password" id="led-tool-password" placeholder="WiFi password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Apply via USB</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
|
|
||||||
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user