feat(patterns,api): pattern OTA, graceful shutdown, driver delivery updates
- Pattern controller/UI and presets patterns tab for OTA to Wi-Fi drivers - Device controller extensions; driver_delivery chunk handling - main: SIGINT/SIGTERM shutdown, TCP/UDP server close coordination - Submodule led-driver: Wi-Fi default transport, lazy espnow import, dynamic patterns Made-with: Cursor
This commit is contained in:
Submodule led-driver updated: aaaf660e9d...fea4e69140
@@ -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("/<id>/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",
|
||||
}
|
||||
|
||||
@@ -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/<name>')
|
||||
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('/<name>/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('/<id>')
|
||||
|
||||
97
src/main.py
97
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -548,14 +548,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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;
|
||||
@@ -568,11 +564,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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 || {}),
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -240,6 +240,9 @@
|
||||
<div id="patterns-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Patterns</h2>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
|
||||
</div>
|
||||
<div id="patterns-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
||||
@@ -247,6 +250,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pattern Editor Modal -->
|
||||
<div id="pattern-editor-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Pattern</h2>
|
||||
<p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p>
|
||||
<div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<label for="pattern-create-name" style="min-width: 7rem;">Name</label>
|
||||
<input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off">
|
||||
</div>
|
||||
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
|
||||
<input type="number" id="pattern-create-min-delay" min="0" value="10">
|
||||
<label for="pattern-create-max-delay">Max delay (ms)</label>
|
||||
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
|
||||
<label for="pattern-create-max-colors">Max colours</label>
|
||||
<input type="number" id="pattern-create-max-colors" min="0" value="10">
|
||||
</div>
|
||||
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
|
||||
<h3 class="muted-text">Readable parameter names</h3>
|
||||
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
|
||||
<div class="n-params-grid">
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n1"></label>
|
||||
<input type="text" id="pattern-create-n1" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n2"></label>
|
||||
<input type="text" id="pattern-create-n2" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n3"></label>
|
||||
<input type="text" id="pattern-create-n3" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n4"></label>
|
||||
<input type="text" id="pattern-create-n4" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n5"></label>
|
||||
<input type="text" id="pattern-create-n5" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n6"></label>
|
||||
<input type="text" id="pattern-create-n6" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n7"></label>
|
||||
<input type="text" id="pattern-create-n7" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
<div class="n-param-group">
|
||||
<label for="pattern-create-n8"></label>
|
||||
<input type="text" id="pattern-create-n8" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
|
||||
<label for="pattern-create-file">Pattern file</label>
|
||||
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">
|
||||
<label for="pattern-create-code" class="muted-text" style="font-size: 0.85em;">Or paste Python source (if no file chosen)</label>
|
||||
<textarea id="pattern-create-code" rows="5" style="width: 100%; font-family: monospace; font-size: 0.85rem;" placeholder="# class MyPattern: ..."></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.35rem; margin-right: auto;">
|
||||
<input type="checkbox" id="pattern-create-overwrite" checked>
|
||||
<span>Overwrite existing file</span>
|
||||
</label>
|
||||
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colour Palette Modal -->
|
||||
<div id="color-palette-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -18,6 +18,28 @@ def _split_serial_envelope(inner_json_str, peer_hex_list):
|
||||
return json.dumps(env, separators=(",", ":"))
|
||||
|
||||
|
||||
def _wifi_message_for_device(msg, device_name):
|
||||
"""
|
||||
For Wi-Fi TCP fanout, narrow a v1 select map to a single device name.
|
||||
Returns the original message when no narrowing applies.
|
||||
"""
|
||||
if not device_name:
|
||||
return msg
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
return msg
|
||||
if not isinstance(body, dict):
|
||||
return msg
|
||||
select = body.get("select")
|
||||
if not isinstance(select, dict):
|
||||
return msg
|
||||
if device_name not in select:
|
||||
return msg
|
||||
body["select"] = {device_name: select[device_name]}
|
||||
return json.dumps(body, separators=(",", ":"))
|
||||
|
||||
|
||||
async def deliver_preset_broadcast_then_per_device(
|
||||
sender,
|
||||
chunk_messages,
|
||||
@@ -129,7 +151,9 @@ async def deliver_json_messages(sender, messages, target_macs, devices_model, de
|
||||
if doc and doc.get("transport") == "wifi":
|
||||
ip = doc.get("address")
|
||||
if ip:
|
||||
wifi_tasks.append(send_json_line_to_ip(ip, msg))
|
||||
name = str(doc.get("name") or "").strip()
|
||||
wifi_msg = _wifi_message_for_device(msg, name)
|
||||
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
|
||||
else:
|
||||
espnow_hex.append(mac)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user