diff --git a/src/controllers/led_tool.py b/src/controllers/led_tool.py new file mode 100644 index 0000000..d39cdfe --- /dev/null +++ b/src/controllers/led_tool.py @@ -0,0 +1,189 @@ +import json +import os +import subprocess +import sys + +from microdot import Microdot +from serial.tools import list_ports + +controller = Microdot() + + +def _repo_root() -> str: + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + +def _led_cli_path() -> str: + return os.path.join(_repo_root(), "led-tool", "cli.py") + + +def _build_led_cli_command(port: str, payload: dict): + cmd = [sys.executable, _led_cli_path(), "--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: + value = payload.get(key) + if value is None: + continue + value_str = str(value).strip() + if value_str == "": + continue + cmd.extend([flag, value_str]) + + return cmd + + +def _run_led_cli_command(cmd, cli_path: str, timeout_s=180): + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout_s, + cwd=os.path.dirname(cli_path), + ) + except subprocess.TimeoutExpired: + return ( + json.dumps({"error": "led-tool command timed out after 180 seconds"}), + 504, + {"Content-Type": "application/json"}, + ) + except Exception as exc: + return ( + json.dumps({"error": str(exc)}), + 500, + {"Content-Type": "application/json"}, + ) + + return ( + json.dumps( + { + "ok": result.returncode == 0, + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "command": cmd, + } + ), + 200, + {"Content-Type": "application/json"}, + ) + + +def _extract_settings_from_stdout(stdout: str): + text = (stdout or "").strip() + if not text: + return None + try: + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else None + except Exception: + return None + + +@controller.get("/ports") +async def list_serial_ports(request): + ports = [] + for info in list_ports.comports(): + ports.append( + { + "device": info.device, + "description": info.description, + "hwid": info.hwid, + } + ) + return ( + json.dumps( + { + "ports": ports, + "led_cli_exists": os.path.exists(_led_cli_path()), + } + ), + 200, + {"Content-Type": "application/json"}, + ) + + +@controller.post("/settings") +async def apply_settings(request): + data = request.json or {} + port = str(data.get("port") or "").strip() + if not port: + return ( + json.dumps({"error": "port is required"}), + 400, + {"Content-Type": "application/json"}, + ) + + cli_path = _led_cli_path() + if not os.path.exists(cli_path): + return ( + json.dumps({"error": "led-tool/cli.py not found"}), + 500, + {"Content-Type": "application/json"}, + ) + + cmd = _build_led_cli_command(port, data) + ["--follow"] + return _run_led_cli_command(cmd, cli_path, timeout_s=None) + + +@controller.post("/reset") +@controller.post("/reset/") +async def reset_device(request): + data = request.json or {} + port = str(data.get("port") or "").strip() + if not port: + return ( + json.dumps({"error": "port is required"}), + 400, + {"Content-Type": "application/json"}, + ) + + cli_path = _led_cli_path() + if not os.path.exists(cli_path): + return ( + json.dumps({"error": "led-tool/cli.py not found"}), + 500, + {"Content-Type": "application/json"}, + ) + + cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"] + return _run_led_cli_command(cmd, cli_path, timeout_s=None) + + +@controller.get("/settings") +async def read_settings(request): + port = str(request.args.get("port") or "").strip() + if not port: + return ( + json.dumps({"error": "port is required"}), + 400, + {"Content-Type": "application/json"}, + ) + + cli_path = _led_cli_path() + if not os.path.exists(cli_path): + return ( + json.dumps({"error": "led-tool/cli.py not found"}), + 500, + {"Content-Type": "application/json"}, + ) + + cmd = [sys.executable, cli_path, "--port", port, "--show"] + body, status, headers = _run_led_cli_command(cmd, cli_path) + if status != 200: + return body, status, headers + data = json.loads(body) + data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "") + return json.dumps(data), status, headers diff --git a/src/main.py b/src/main.py index 0f9e82f..e8fcef4 100644 --- a/src/main.py +++ b/src/main.py @@ -21,6 +21,7 @@ import controllers.scene as scene import controllers.pattern as pattern import controllers.settings as settings_controller import controllers.device as device_controller +import controllers.led_tool as led_tool_controller from models.transport import get_sender, set_sender, get_current_sender from models.device import Device, normalize_mac from models import wifi_ws_clients as tcp_client_registry @@ -273,6 +274,7 @@ async def main(port=80): app.mount(pattern.controller, '/patterns') app.mount(settings_controller.controller, '/settings') app.mount(device_controller.controller, '/devices') + app.mount(led_tool_controller.controller, '/led-tool') tcp_client_registry.set_settings(settings) tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status) diff --git a/src/static/led_tool.js b/src/static/led_tool.js new file mode 100644 index 0000000..c3b11b1 --- /dev/null +++ b/src/static/led_tool.js @@ -0,0 +1,255 @@ +document.addEventListener('DOMContentLoaded', () => { + const openBtn = document.getElementById('led-tool-btn'); + const modal = document.getElementById('led-tool-modal'); + const closeBtn = document.getElementById('led-tool-close-btn'); + const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn'); + 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) { + 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 = ''; + + 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', () => { + modal.classList.add('active'); + loadPorts(); + }); + + if (closeBtn) { + closeBtn.addEventListener('click', () => { + modal.classList.remove('active'); + }); + } + + 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); + } + }); +}); diff --git a/src/templates/index.html b/src/templates/index.html index c34cca1..9ed0ae2 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -22,6 +22,7 @@ + @@ -36,6 +37,7 @@ + @@ -364,6 +366,13 @@
  • Colour Palette: build profile colours and use From Palette in preset editor to add linked colours (badge P) that update when palette colours change.
  • +

    What led-tool does

    + + @@ -438,9 +447,86 @@ + + + +