diff --git a/led-driver b/led-driver index aaaf660..fea4e69 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit aaaf660e9d1c380697e6c30046b30bcbd893e03f +Subproject commit fea4e69140b7142563159daced00972c5275acb5 diff --git a/src/controllers/device.py b/src/controllers/device.py index 14c6f9e..cd6cbf3 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -14,6 +14,7 @@ from models.tcp_clients import ( from util.espnow_message import build_message import asyncio import json +import os # Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire). _IDENTIFY_PRESET_KEY = "__identify" @@ -72,6 +73,37 @@ def _device_json_with_live_status(dev_dict): return row +def _driver_patterns_dir(): + here = os.path.dirname(__file__) + return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns")) + + +def _safe_pattern_filename(name): + if not isinstance(name, str): + return False + if not name.endswith(".py"): + return False + if "/" in name or "\\" in name or ".." in name: + return False + return True + + +def _build_patterns_manifest(host): + base_dir = _driver_patterns_dir() + names = sorted(os.listdir(base_dir)) + files = [] + for name in names: + if not _safe_pattern_filename(name) or name == "__init__.py": + continue + files.append( + { + "name": name, + "url": "http://%s/patterns/ota/file/%s" % (host, name), + } + ) + return {"files": files} + + async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name): try: await asyncio.sleep(IDENTIFY_OFF_DELAY_S) @@ -259,3 +291,56 @@ async def identify_device(request, id): return json.dumps({"message": "Identify sent"}), 200, { "Content-Type": "application/json", } + + +@controller.post("//patterns/push") +async def push_patterns_ota(request, id): + """ + Ask a Wi-Fi LED driver to pull pattern files from this server over HTTP. + + Body (optional): + {"manifest": "http://host:port/patterns/ota/manifest"} + """ + dev = devices.read(id) + if not dev: + return json.dumps({"error": "Device not found"}), 404, { + "Content-Type": "application/json", + } + if (dev.get("transport") or "").lower() != "wifi": + return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, { + "Content-Type": "application/json", + } + wifi_ip = str(dev.get("address") or "").strip() + if not wifi_ip: + return json.dumps({"error": "Device has no IP address"}), 400, { + "Content-Type": "application/json", + } + + body = request.json or {} + manifest_payload = body.get("manifest") + if manifest_payload is None: + host = request.headers.get("Host", "") + if not host: + return json.dumps({"error": "Missing Host header"}), 400, { + "Content-Type": "application/json", + } + try: + manifest_payload = _build_patterns_manifest(host) + except OSError as e: + return json.dumps({"error": str(e)}), 500, { + "Content-Type": "application/json", + } + if not isinstance(manifest_payload, (str, dict)): + return json.dumps({"error": "manifest must be a URL string or manifest object"}), 400, { + "Content-Type": "application/json", + } + + msg = json.dumps({"v": "1", "manifest": manifest_payload}, separators=(",", ":")) + ok = await send_json_line_to_ip(wifi_ip, msg) + if not ok: + return json.dumps({"error": "Wi-Fi driver not connected"}), 503, { + "Content-Type": "application/json", + } + return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, { + "Content-Type": "application/json", + } diff --git a/src/controllers/pattern.py b/src/controllers/pattern.py index dc0cf37..88e1a89 100644 --- a/src/controllers/pattern.py +++ b/src/controllers/pattern.py @@ -1,19 +1,67 @@ from microdot import Microdot from models.pattern import Pattern +from models.device import Device +from models.tcp_clients import send_json_line_to_ip import json +import re import sys +import os controller = Microdot() patterns = Pattern() + +def _project_root(): + """Project root (parent of ``src/``). CWD is often ``src/`` when running ``main.py``.""" + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.abspath(os.path.join(here, "..", "..")) + + +def _driver_patterns_dir(): + here = os.path.dirname(__file__) + return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns")) + + +def _safe_pattern_filename(name): + if not isinstance(name, str): + return False + if not name.endswith(".py"): + return False + if "/" in name or "\\" in name or ".." in name: + return False + return True + + +_PATTERN_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$") + + +def _normalize_pattern_key(raw): + """Pattern id / module basename (no .py).""" + if not isinstance(raw, str): + return "" + s = raw.strip() + if s.lower().endswith(".py"): + s = s[:-3].strip() + return s + + +def _valid_pattern_key(key): + return bool(key and _PATTERN_KEY_RE.match(key)) + def load_pattern_definitions(): """Load pattern definitions from pattern.json file.""" try: - # Try different paths for local development vs MicroPython - paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json'] + root = _project_root() + paths = [ + os.path.join(root, "db", "pattern.json"), + os.path.join(root, "pattern.json"), + "db/pattern.json", + "pattern.json", + "/db/pattern.json", + ] for path in paths: try: - with open(path, 'r') as f: + with open(path, "r") as f: return json.load(f) except OSError: continue @@ -22,16 +70,301 @@ def load_pattern_definitions(): print(f"Error loading pattern.json: {e}") return {} + +def load_driver_pattern_names(): + """List available pattern module names from led-driver/src/patterns.""" + try: + names = [] + for filename in os.listdir(_driver_patterns_dir()): + if not _safe_pattern_filename(filename) or filename == "__init__.py": + continue + names.append(filename[:-3]) + names.sort() + return names + except OSError: + return [] + + +def build_runtime_pattern_map(): + """ + Runtime pattern map for UI menus. + Keep pattern DB metadata as primary, then add any local driver pattern files + missing from the DB so new OTA files still appear in menus. + """ + definitions = load_pattern_definitions() + available = load_driver_pattern_names() + result = {} + for name, meta in definitions.items(): + result[name] = dict(meta) if isinstance(meta, dict) else {} + for name in available: + if name not in result: + result[name] = {} + return result + @controller.get('/definitions') async def get_pattern_definitions(request): - """Get pattern definitions from pattern.json.""" - definitions = load_pattern_definitions() + """Get definitions for patterns currently available on the driver.""" + definitions = build_runtime_pattern_map() return json.dumps(definitions), 200, {'Content-Type': 'application/json'} + +@controller.get('/ota/manifest') +async def ota_manifest(request): + """Manifest of driver pattern source files for OTA pulls.""" + base_dir = _driver_patterns_dir() + host = request.headers.get("Host", "") + if not host: + return json.dumps({"error": "Missing Host header"}), 400, { + "Content-Type": "application/json" + } + try: + names = sorted(os.listdir(base_dir)) + except OSError as e: + return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + + files = [] + for name in names: + if not _safe_pattern_filename(name) or name == "__init__.py": + continue + files.append({ + "name": name, + "url": "http://%s/patterns/ota/file/%s" % (host, name), + }) + + return json.dumps({"files": files}), 200, {"Content-Type": "application/json"} + + +@controller.get('/ota/file/') +async def ota_pattern_file(request, name): + """Serve one driver pattern source file for OTA pulls.""" + if not _safe_pattern_filename(name) or name == "__init__.py": + return json.dumps({"error": "Invalid filename"}), 400, { + "Content-Type": "application/json" + } + path = os.path.join(_driver_patterns_dir(), name) + try: + with open(path, "r") as f: + content = f.read() + except OSError: + return json.dumps({"error": "Pattern file not found"}), 404, { + "Content-Type": "application/json" + } + return content, 200, {"Content-Type": "text/plain; charset=utf-8"} + + +@controller.post('//send') +async def send_pattern_to_device(request, name): + """Tell Wi-Fi driver(s) to download one pattern source file over HTTP.""" + if not isinstance(name, str): + return json.dumps({"error": "Invalid pattern name"}), 400, { + "Content-Type": "application/json" + } + filename = name if name.endswith(".py") else (name + ".py") + if not _safe_pattern_filename(filename) or filename == "__init__.py": + return json.dumps({"error": "Invalid pattern filename"}), 400, { + "Content-Type": "application/json" + } + + devices = Device() + body = request.json or {} + requested_device_id = str(body.get("device_id") or "").strip() + + path = os.path.join(_driver_patterns_dir(), filename) + if not os.path.exists(path): + return json.dumps({"error": "Pattern file not found"}), 404, { + "Content-Type": "application/json" + } + + file_url = "/patterns/ota/file/%s" % filename + + msg = json.dumps( + { + "v": "1", + "manifest": { + "files": [ + { + "name": filename, + "url": file_url, + } + ] + }, + }, + separators=(",", ":"), + ) + target_ids = [] + if requested_device_id: + dev = devices.read(requested_device_id) + if not dev: + return json.dumps({"error": "Device not found"}), 404, { + "Content-Type": "application/json" + } + if (dev.get("transport") or "").lower() != "wifi": + return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, { + "Content-Type": "application/json" + } + target_ids = [requested_device_id] + else: + for did in devices.list(): + dev = devices.read(did) or {} + if (dev.get("transport") or "").lower() == "wifi": + target_ids.append(str(did)) + if not target_ids: + return json.dumps({"error": "No Wi-Fi devices found"}), 404, { + "Content-Type": "application/json" + } + + sent_ids = [] + for did in target_ids: + dev = devices.read(did) or {} + ip = str(dev.get("address") or "").strip() + if not ip: + continue + ok = await send_json_line_to_ip(ip, msg) + if ok: + sent_ids.append(did) + + if not sent_ids: + return json.dumps({"error": "No Wi-Fi drivers connected"}), 503, { + "Content-Type": "application/json" + } + return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, { + "Content-Type": "application/json" + } + + +@controller.post('/upload') +async def upload_pattern_file(request): + """ + Upload a pattern source file to led-controller local storage. + + Body JSON: + { + "name": "sparkle.py" | "sparkle", + "code": "class Sparkle: ...", + "overwrite": true | false # optional, default true + } + """ + data = request.json or {} + raw_name = data.get("name") or data.get("filename") + code = data.get("code") + overwrite = data.get("overwrite", True) + overwrite = bool(overwrite) + + if not isinstance(raw_name, str) or not raw_name.strip(): + return json.dumps({"error": "name is required"}), 400, { + "Content-Type": "application/json" + } + filename = raw_name.strip() + if not filename.endswith(".py"): + filename += ".py" + if not _safe_pattern_filename(filename) or filename == "__init__.py": + return json.dumps({"error": "invalid pattern filename"}), 400, { + "Content-Type": "application/json" + } + if not isinstance(code, str) or not code.strip(): + return json.dumps({"error": "code is required"}), 400, { + "Content-Type": "application/json" + } + + path = os.path.join(_driver_patterns_dir(), filename) + exists = os.path.exists(path) + if exists and not overwrite: + return json.dumps({"error": "pattern file already exists", "name": filename}), 409, { + "Content-Type": "application/json" + } + + try: + with open(path, "w") as f: + f.write(code) + except OSError as e: + return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + + return json.dumps({ + "message": "Pattern uploaded", + "name": filename, + "overwrote": bool(exists), + }), 201, {"Content-Type": "application/json"} + + +@controller.post('/driver') +async def create_driver_pattern(request): + """ + Create a driver pattern: save ``.py`` under led-driver/src/patterns and + metadata in db/pattern.json (Pattern model). + + Body JSON: + name, code (required), + min_delay, max_delay, max_colors (optional numbers), + n1..n8 (optional string labels), + overwrite (optional, default true). + """ + data = request.json or {} + key = _normalize_pattern_key(data.get("name") or "") + if not _valid_pattern_key(key): + return json.dumps({ + "error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)", + }), 400, {"Content-Type": "application/json"} + + code = data.get("code") + if not isinstance(code, str) or not code.strip(): + return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, { + "Content-Type": "application/json" + } + + overwrite = bool(data.get("overwrite", True)) + + filename = key + ".py" + py_path = os.path.join(_driver_patterns_dir(), filename) + if os.path.exists(py_path) and not overwrite: + return json.dumps({"error": "pattern file already exists", "name": filename}), 409, { + "Content-Type": "application/json" + } + + meta = {} + for fld in ("min_delay", "max_delay", "max_colors"): + if fld not in data: + continue + try: + meta[fld] = int(data[fld]) + except (TypeError, ValueError): + return json.dumps({"error": "%s must be an integer" % fld}), 400, { + "Content-Type": "application/json" + } + + for i in range(1, 9): + nk = "n%d" % i + if nk not in data: + continue + lab = data[nk] + if lab is None: + continue + s = str(lab).strip() + if s: + meta[nk] = s + + try: + with open(py_path, "w") as f: + f.write(code) + except OSError as e: + return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + + if patterns.read(key): + patterns.update(key, meta) + else: + patterns.create(key, meta) + + return json.dumps({ + "message": "Pattern created", + "name": key, + "file": filename, + "metadata": patterns.read(key), + }), 201, {"Content-Type": "application/json"} + + @controller.get('') async def list_patterns(request): - """List all patterns.""" - return json.dumps(patterns), 200, {'Content-Type': 'application/json'} + """List patterns for UI (DB metadata + local driver additions).""" + return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'} @controller.get('/') diff --git a/src/main.py b/src/main.py index 5962ceb..ee37f52 100644 --- a/src/main.py +++ b/src/main.py @@ -2,6 +2,7 @@ import asyncio import errno import json import os +import signal import socket import threading import traceback @@ -127,12 +128,17 @@ def _register_udp_device_sync( traceback.print_exception(type(e), e, e.__traceback__) -async def _handle_udp_discovery(sock) -> None: +async def _handle_udp_discovery(sock, udp_holder=None) -> None: while True: try: data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048) except asyncio.CancelledError: raise + except OSError as e: + if udp_holder and udp_holder.get("closing"): + break + print(f"[UDP] recv failed: {e!r}") + continue except Exception as e: print(f"[UDP] recv failed: {e!r}") continue @@ -157,7 +163,7 @@ async def _handle_udp_discovery(sock) -> None: print(f"[UDP] echo send failed: {e!r}") -async def _run_udp_discovery_server() -> None: +async def _run_udp_discovery_server(udp_holder=None) -> None: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setblocking(False) try: @@ -169,10 +175,14 @@ async def _run_udp_discovery_server() -> None: except (AttributeError, OSError): pass sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT)) + if udp_holder is not None: + udp_holder["sock"] = sock print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}") try: - await _handle_udp_discovery(sock) + await _handle_udp_discovery(sock, udp_holder) finally: + if udp_holder is not None: + udp_holder.pop("sock", None) try: sock.close() except Exception: @@ -275,15 +285,21 @@ async def _send_bridge_wifi_channel(settings, sender): print(f"[startup] bridge channel message failed: {e}") -async def _run_tcp_server(settings): +async def _run_tcp_server(settings, tcp_holder=None): if not settings.get("tcp_enabled", True): print("TCP server disabled (tcp_enabled=false)") return port = int(settings.get("tcp_port", 8765)) server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port) print(f"TCP server listening on 0.0.0.0:{port}") - async with server: - await server.serve_forever() + if tcp_holder is not None: + tcp_holder["server"] = server + try: + async with server: + await server.serve_forever() + finally: + if tcp_holder is not None: + tcp_holder.pop("server", None) async def main(port=80): @@ -395,25 +411,60 @@ async def main(port=80): Device() await _send_bridge_wifi_channel(settings, sender) - # Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface - # here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP - # never starts, which clears Wi-Fi presence dots. + tcp_holder = {} + udp_holder = {"closing": False} + loop = asyncio.get_running_loop() + + def _graceful_shutdown(*_args): + print("[server] shutting down...") + udp_holder["closing"] = True + u = udp_holder.get("sock") + if u is not None: + try: + u.close() + except OSError: + pass + s = tcp_holder.get("server") + if s is not None: + s.close() + if getattr(app, "server", None) is not None: + app.shutdown() + + shutdown_handlers_registered = False try: - await asyncio.gather( - app.start_server(host="0.0.0.0", port=port), - _run_tcp_server(settings), - _run_udp_discovery_server(), - ) - except OSError as e: - if e.errno == errno.EADDRINUSE: - tcp_p = int(settings.get("tcp_port", 8765)) - print( - f"[server] bind failed (address already in use): {e!s}\n" - f"[server] HTTP is configured for port {port} (env PORT); " - f"Wi-Fi LED drivers use tcp_port {tcp_p}. " - f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run" + try: + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, _graceful_shutdown) + shutdown_handlers_registered = True + except (NotImplementedError, RuntimeError): + pass + + # Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface + # here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP + # never starts, which clears Wi-Fi presence dots. + try: + await asyncio.gather( + app.start_server(host="0.0.0.0", port=port), + _run_tcp_server(settings, tcp_holder), + _run_udp_discovery_server(udp_holder), ) - raise + except OSError as e: + if e.errno == errno.EADDRINUSE: + tcp_p = int(settings.get("tcp_port", 8765)) + print( + f"[server] bind failed (address already in use): {e!s}\n" + f"[server] HTTP is configured for port {port} (env PORT); " + f"Wi-Fi LED drivers use tcp_port {tcp_p}. " + f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run" + ) + raise + finally: + if shutdown_handlers_registered: + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.remove_signal_handler(sig) + except (NotImplementedError, OSError, ValueError): + pass if __name__ == "__main__": import os diff --git a/src/static/patterns.js b/src/static/patterns.js index 9de03ea..a2df5b0 100644 --- a/src/static/patterns.js +++ b/src/static/patterns.js @@ -3,11 +3,301 @@ document.addEventListener('DOMContentLoaded', () => { const patternsModal = document.getElementById('patterns-modal'); const patternsCloseButton = document.getElementById('patterns-close-btn'); const patternsList = document.getElementById('patterns-list'); + const patternAddButton = document.getElementById('pattern-add-btn'); + const patternEditorModal = document.getElementById('pattern-editor-modal'); + const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn'); + const patternCreateBtn = document.getElementById('pattern-create-btn'); + const patternCreateName = document.getElementById('pattern-create-name'); + const patternCreateMinDelay = document.getElementById('pattern-create-min-delay'); + const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay'); + const patternCreateMaxColors = document.getElementById('pattern-create-max-colors'); + const patternCreateFile = document.getElementById('pattern-create-file'); + const patternCreateCode = document.getElementById('pattern-create-code'); + const patternCreateOverwrite = document.getElementById('pattern-create-overwrite'); + const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) => + document.getElementById(`pattern-create-n${i}`), + ); + const patternCreateNSection = document.getElementById('pattern-create-n-section'); + const patternCreateNEmpty = document.getElementById('pattern-create-n-empty'); if (!patternsButton || !patternsModal || !patternsList) { return; } + const nReadableStringFromMeta = (meta, key) => { + if (!meta || typeof meta !== 'object') { + return ''; + } + const pm = meta.parameter_mappings; + if (pm && typeof pm === 'object' && typeof pm[key] === 'string') { + const s = pm[key].trim(); + if (s) { + return s; + } + } + if (typeof meta[key] === 'string') { + return meta[key].trim(); + } + return ''; + }; + + const setPatternEditorNFields = (mode, data) => { + const meta = data && typeof data === 'object' ? data : {}; + let visible = 0; + const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid'); + const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3'); + + for (let i = 1; i <= 8; i += 1) { + const key = `n${i}`; + const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`); + const inputEl = document.getElementById(`pattern-create-${key}`); + const groupEl = labelEl ? labelEl.closest('.n-param-group') : null; + + if (mode === 'create') { + if (labelEl) { + labelEl.textContent = ''; + labelEl.style.display = 'none'; + } + if (inputEl) { + inputEl.value = ''; + inputEl.placeholder = 'Readable name (optional)'; + inputEl.removeAttribute('aria-label'); + } + if (groupEl) { + groupEl.style.display = ''; + } + continue; + } + + const readable = nReadableStringFromMeta(meta, key); + const show = Boolean(readable); + if (labelEl) { + labelEl.textContent = ''; + labelEl.style.display = 'none'; + } + if (inputEl) { + inputEl.value = show ? readable : ''; + inputEl.placeholder = ''; + if (show) { + inputEl.setAttribute('aria-label', readable); + } else { + inputEl.removeAttribute('aria-label'); + inputEl.value = ''; + } + } + if (groupEl) { + groupEl.style.display = show ? '' : 'none'; + } + if (show) { + visible += 1; + } + } + + if (mode === 'create') { + if (patternCreateNEmpty) { + patternCreateNEmpty.style.display = 'none'; + } + if (grid) { + grid.style.display = ''; + } + if (h3) { + h3.style.display = ''; + } + if (patternCreateNSection) { + patternCreateNSection.style.display = ''; + } + return; + } + + if (patternCreateNEmpty) { + patternCreateNEmpty.style.display = visible === 0 ? '' : 'none'; + } + if (grid) { + grid.style.display = visible === 0 ? 'none' : ''; + } + if (h3) { + h3.style.display = visible === 0 ? 'none' : ''; + } + }; + + const readFileAsText = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(reader.error || new Error('read failed')); + reader.readAsText(file); + }); + + const collectCreatePayload = async () => { + const name = patternCreateName ? patternCreateName.value.trim() : ''; + if (!name) { + throw new Error('Pattern name is required.'); + } + let code = ''; + const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0]; + if (fileInput) { + code = await readFileAsText(fileInput); + } else if (patternCreateCode && patternCreateCode.value.trim()) { + code = patternCreateCode.value; + } + if (!code.trim()) { + throw new Error('Choose a .py file or paste source code.'); + } + + const payload = { + name, + code, + min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0, + max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0, + max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0, + overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked), + }; + + patternCreateN.forEach((el, idx) => { + const key = `n${idx + 1}`; + if (el && el.value.trim()) { + payload[key] = el.value.trim(); + } + }); + + return payload; + }; + + const resetCreateForm = () => { + if (patternCreateName) patternCreateName.value = ''; + if (patternCreateFile) patternCreateFile.value = ''; + if (patternCreateCode) patternCreateCode.value = ''; + if (patternCreateMinDelay) patternCreateMinDelay.value = '10'; + if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000'; + if (patternCreateMaxColors) patternCreateMaxColors.value = '10'; + patternCreateN.forEach((el) => { + if (el) el.value = ''; + }); + if (patternCreateOverwrite) patternCreateOverwrite.checked = true; + setPatternEditorNFields('create', {}); + }; + + if (patternCreateBtn) { + patternCreateBtn.addEventListener('click', async () => { + try { + const payload = await collectCreatePayload(); + const response = await fetch('/patterns/driver', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error((data && data.error) || 'Create failed'); + } + alert(data.message || 'Pattern created.'); + resetCreateForm(); + if (patternEditorModal) { + patternEditorModal.classList.remove('active'); + } + await loadPatterns(); + } catch (e) { + console.error('Create pattern failed:', e); + alert(e.message || 'Failed to create pattern.'); + } + }); + } + + const sendPatternToDevices = async (patternName) => { + const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({}), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error((data && data.error) || 'Failed to send pattern'); + } + const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null; + if (sentCount === null) { + alert(`Sent "${patternName}" to devices.`); + } else { + alert(`Sent "${patternName}" to ${sentCount} device(s).`); + } + }; + + const loadPatternMetadata = async (patternName, fallbackData) => { + const raw = String(patternName || '').trim(); + const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw; + try { + const response = await fetch('/patterns/definitions', { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to load pattern definitions'); + } + const definitions = await response.json(); + if (definitions && typeof definitions === 'object') { + if (definitions[raw]) { + return definitions[raw]; + } + if (norm && definitions[norm]) { + return definitions[norm]; + } + if (norm) { + const lower = norm.toLowerCase(); + const matched = Object.keys(definitions).find( + (k) => String(k).toLowerCase() === lower, + ); + if (matched) { + return definitions[matched]; + } + } + } + } catch (error) { + console.error('Load pattern definitions failed:', error); + } + return fallbackData || {}; + }; + + const loadPatternIntoEditor = async (patternName, fallbackData) => { + const data = await loadPatternMetadata(patternName, fallbackData); + if (patternCreateName) { + patternCreateName.value = patternName; + } + if (patternCreateMinDelay) { + patternCreateMinDelay.value = + data && data.min_delay !== undefined ? String(data.min_delay) : '10'; + } + if (patternCreateMaxDelay) { + patternCreateMaxDelay.value = + data && data.max_delay !== undefined ? String(data.max_delay) : '10000'; + } + if (patternCreateMaxColors) { + patternCreateMaxColors.value = + data && data.max_colors !== undefined ? String(data.max_colors) : '10'; + } + setPatternEditorNFields('edit', data); + if (patternCreateOverwrite) { + patternCreateOverwrite.checked = true; + } + if (patternCreateFile) { + patternCreateFile.value = ''; + } + + try { + const response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, { + headers: { Accept: 'text/plain' }, + }); + if (!response.ok) { + throw new Error('Failed to load pattern file'); + } + const source = await response.text(); + if (patternCreateCode) { + patternCreateCode.value = source || ''; + patternCreateCode.focus(); + } + } catch (error) { + console.error('Load pattern source failed:', error); + alert('Could not load pattern source into editor.'); + } + }; + const renderPatterns = (patterns) => { patternsList.innerHTML = ''; const entries = Object.entries(patterns || {}); @@ -32,13 +322,37 @@ document.addEventListener('DOMContentLoaded', () => { details.style.color = '#aaa'; details.style.fontSize = '0.85em'; + const sendBtn = document.createElement('button'); + sendBtn.className = 'btn btn-primary btn-small'; + sendBtn.textContent = 'Send'; + sendBtn.addEventListener('click', async () => { + try { + await sendPatternToDevices(patternName); + } catch (error) { + console.error('Send pattern failed:', error); + alert(error.message || 'Failed to send pattern.'); + } + }); + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-secondary btn-small'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', async () => { + if (patternEditorModal) { + patternEditorModal.classList.add('active'); + } + await loadPatternIntoEditor(patternName, data || {}); + }); + row.appendChild(label); row.appendChild(details); + row.appendChild(editBtn); + row.appendChild(sendBtn); patternsList.appendChild(row); }); }; - const loadPatterns = async () => { + async function loadPatterns() { patternsList.innerHTML = ''; const loading = document.createElement('p'); loading.className = 'muted-text'; @@ -62,7 +376,7 @@ document.addEventListener('DOMContentLoaded', () => { errorMessage.textContent = 'Failed to load patterns.'; patternsList.appendChild(errorMessage); } - }; + } const openModal = () => { patternsModal.classList.add('active'); @@ -74,6 +388,21 @@ document.addEventListener('DOMContentLoaded', () => { }; patternsButton.addEventListener('click', openModal); + if (patternAddButton) { + patternAddButton.addEventListener('click', () => { + resetCreateForm(); + if (patternEditorModal) { + patternEditorModal.classList.add('active'); + } + }); + } + if (patternEditorCloseButton) { + patternEditorCloseButton.addEventListener('click', () => { + if (patternEditorModal) { + patternEditorModal.classList.remove('active'); + } + }); + } if (patternsCloseButton) { patternsCloseButton.addEventListener('click', closeModal); } diff --git a/src/static/presets.js b/src/static/presets.js index dfaf335..409eab7 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -547,32 +547,26 @@ document.addEventListener('DOMContentLoaded', () => { presetPatternInput.style.backgroundColor = ''; presetPatternInput.style.cursor = ''; } - - // Update labels and visibility based on pattern - updatePresetNLabels(patternName); - + // Get pattern config to map descriptive names back to n keys const patternConfig = cachedPatterns && cachedPatterns[patternName]; const nToLabel = {}; if (patternConfig && typeof patternConfig === 'object') { - // Now n keys are keys, labels are values Object.entries(patternConfig).forEach(([nKey, label]) => { if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') { nToLabel[nKey] = label; } }); } - + // Set n values, checking both n keys and descriptive names for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; const inputEl = document.getElementById(`preset-${nKey}-input`); if (inputEl) { - // First check if preset has n key directly if (preset[nKey] !== undefined) { inputEl.value = preset[nKey] || 0; } else { - // Check if preset has descriptive name (from pattern.json mapping) const label = nToLabel[nKey]; if (label && preset[label] !== undefined) { inputEl.value = preset[label] || 0; @@ -582,6 +576,9 @@ document.addEventListener('DOMContentLoaded', () => { } } } + + // After values: show only mapped n params with labels from pattern.json; clear hidden inputs + updatePresetNLabels(patternName); updatePresetEditorTabActionsVisibility(); }; @@ -774,44 +771,65 @@ document.addEventListener('DOMContentLoaded', () => { }; const updatePresetNLabels = (patternName) => { + const rawPatternName = String(patternName || '').trim(); + const normalizedPatternName = rawPatternName.endsWith('.py') + ? rawPatternName.slice(0, -3) + : rawPatternName; + let patternConfig = + (cachedPatterns && cachedPatterns[rawPatternName]) || + (cachedPatterns && cachedPatterns[normalizedPatternName]) || + null; + if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') { + const lower = normalizedPatternName.toLowerCase(); + const matchedKey = Object.keys(cachedPatterns).find( + (k) => String(k).toLowerCase() === lower, + ); + if (matchedKey) { + patternConfig = cachedPatterns[matchedKey]; + } + } + if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') { + patternConfig = patternConfig.data; + } + if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') { + patternConfig = patternConfig.parameter_mappings; + } const labels = {}; const visibleNKeys = new Set(); - - // Initialize all labels with default n1:, n2:, etc. - for (let i = 1; i <= 8; i++) { - labels[`n${i}`] = `n${i}:`; - } - - const patternConfig = cachedPatterns && cachedPatterns[patternName]; + if (patternConfig && typeof patternConfig === 'object') { - // Now n values are keys and descriptive names are values Object.entries(patternConfig).forEach(([key, label]) => { if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') { - labels[key] = `${label}:`; - visibleNKeys.add(key); // Mark this n key as visible + const text = label.trim(); + if (text) { + labels[key] = `${text}:`; + visibleNKeys.add(key); + } } }); } - - // Update labels and show/hide input groups + for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; const labelEl = document.getElementById(`preset-${nKey}-label`); - const inputEl = document.getElementById(`preset-${nKey}-input`); const groupEl = labelEl ? labelEl.closest('.n-param-group') : null; - + const show = visibleNKeys.has(nKey); + const inputEl = document.getElementById(`preset-${nKey}-input`); + if (labelEl) { - labelEl.textContent = labels[nKey]; + labelEl.textContent = show ? labels[nKey] : ''; } - - // Show or hide the entire group based on whether it has a mapping if (groupEl) { - if (visibleNKeys.has(nKey)) { - groupEl.style.display = ''; // Show - } else { - groupEl.style.display = 'none'; // Hide - } + groupEl.style.display = show ? '' : 'none'; } + if (inputEl && !show) { + inputEl.value = '0'; + } + } + + const nGrid = presetEditorModal && presetEditorModal.querySelector('.n-params-grid'); + if (nGrid) { + nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none'; } }; @@ -845,6 +863,7 @@ document.addEventListener('DOMContentLoaded', () => { editButton.addEventListener('click', async () => { currentEditId = presetId; currentEditTabId = null; + await loadPatterns(); const paletteColors = await getCurrentProfilePaletteColors(); const presetForEditor = { ...(preset || {}), diff --git a/src/static/style.css b/src/static/style.css index 6645400..70a3ce6 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -458,22 +458,28 @@ body.preset-ui-run .edit-mode-only { .n-param-group { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; + justify-content: space-between; } .n-param-group label { - min-width: 40px; + flex: 1; + min-width: 0; font-weight: 500; } .n-input { - flex: 1; + flex: 0 0 var(--n-input-width, 5ch); + width: var(--n-input-width, 5ch); + max-width: 100%; + box-sizing: border-box; padding: 0.5rem; background-color: #3a3a3a; color: white; border: 1px solid #4a4a4a; border-radius: 4px; font-size: 1rem; + text-align: right; } .n-input:focus { @@ -1251,6 +1257,48 @@ body.preset-ui-run .edit-mode-only { flex: 1; display: flex; flex-direction: column; + align-items: flex-end; +} + +.preset-editor-field label { + align-self: stretch; +} + +.preset-editor-field input[type="number"] { + width: var(--n-input-width, 5ch); + max-width: 100%; + box-sizing: border-box; + text-align: right; +} + +/* Pattern editor: numeric metadata row */ +#pattern-editor-modal input[type="number"] { + width: var(--n-input-width, 5ch); + max-width: 100%; + box-sizing: border-box; + text-align: right; +} + +/* Pattern editor: human-readable n labels (text), full width */ +#pattern-editor-modal .n-params-grid { + grid-template-columns: 1fr; +} + +#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) { + justify-content: stretch; +} + +#pattern-editor-modal .pattern-n-readable-input { + flex: 1 1 auto; + width: 100%; + min-width: 0; + text-align: left; +} + +@supports not selector(:has(*)) { + #pattern-editor-modal #pattern-create-n-section .n-param-group { + justify-content: stretch; + } } /* Settings modal */ diff --git a/src/templates/index.html b/src/templates/index.html index db1e090..468a207 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -240,6 +240,9 @@