Compare commits
11 Commits
fb8141b320
...
preset
| Author | SHA1 | Date | |
|---|---|---|---|
| ff92451a76 | |||
| 60485bc06a | |||
| f6f299c3e5 | |||
| 66485f5c59 | |||
| 5f9ff9bcc9 | |||
| 35730b36f0 | |||
| d516833cc3 | |||
| 220be64dec | |||
| b433477c64 | |||
| 43b7047c57 | |||
| 167417d1ec |
@@ -1 +1 @@
|
|||||||
{"24ec4acaffcc": {"id": "24ec4acaffcc", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.171", "default_pattern": null, "zones": []}, "188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}
|
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 0
|
"max_colors": 0
|
||||||
},
|
},
|
||||||
|
"colour_cycle": {
|
||||||
|
"n1": "Step Rate",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10
|
||||||
|
},
|
||||||
"transition": {
|
"transition": {
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
@@ -50,5 +56,37 @@
|
|||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 10
|
"max_colors": 10
|
||||||
|
},
|
||||||
|
"flicker": {
|
||||||
|
"n1": "Min brightness",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10
|
||||||
|
},
|
||||||
|
"flame": {
|
||||||
|
"n1": "Min brightness",
|
||||||
|
"n2": "Breath period (ms)",
|
||||||
|
"n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)",
|
||||||
|
"n4": "Spark gap max (ms)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10
|
||||||
|
},
|
||||||
|
"twinkle": {
|
||||||
|
"n1": "Twinkle activity (1–255, higher = more changes)",
|
||||||
|
"n2": "Density (0–255, higher = more of the strip lit)",
|
||||||
|
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
||||||
|
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10
|
||||||
|
},
|
||||||
|
"radiate": {
|
||||||
|
"n1": "Node spacing (LEDs)",
|
||||||
|
"n2": "Out time (ms)",
|
||||||
|
"n3": "In time (ms)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"1": {"name": "default", "names": ["led-188b0e1560a8", "a", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "10", "11"], ["9", "12", "1"], ["13", "37", "6"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37", "6"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41"], "default_preset": "4"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||||
Submodule led-driver updated: 87bd0338bd...428ed8b884
2
led-tool
2
led-tool
Submodule led-tool updated: 9e72c62481...713cd6e9a1
@@ -16,6 +16,8 @@ from util.espnow_message import build_message
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
|
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
|
||||||
_IDENTIFY_PRESET_KEY = "__identify"
|
_IDENTIFY_PRESET_KEY = "__identify"
|
||||||
@@ -84,20 +86,49 @@ def _safe_pattern_filename(name):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _build_patterns_manifest(host):
|
def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, timeout_s=10.0):
|
||||||
base_dir = driver_patterns_dir()
|
"""POST source to driver /patterns/upload?name=...&reload=...; return True on 2xx."""
|
||||||
names = sorted(os.listdir(base_dir))
|
if not isinstance(ip, str) or not ip.strip():
|
||||||
files = []
|
return False
|
||||||
for name in names:
|
if not isinstance(filename, str) or not filename:
|
||||||
if not _safe_pattern_filename(name) or name == "__init__.py":
|
return False
|
||||||
continue
|
if not isinstance(code_text, str):
|
||||||
files.append(
|
return False
|
||||||
{
|
|
||||||
"name": name,
|
name_q = quote(filename, safe="")
|
||||||
"url": "http://%s/patterns/ota/file/%s" % (host, name),
|
reload_q = "1" if reload_patterns else "0"
|
||||||
}
|
path = "/patterns/upload?name=%s&reload=%s" % (name_q, reload_q)
|
||||||
)
|
body = code_text.encode("utf-8")
|
||||||
return {"files": files}
|
req = (
|
||||||
|
"POST %s HTTP/1.1\r\n"
|
||||||
|
"Host: %s\r\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\r\n"
|
||||||
|
"Content-Length: %d\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"\r\n" % (path, ip, len(body))
|
||||||
|
).encode("utf-8") + body
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
sock.settimeout(timeout_s)
|
||||||
|
sock.connect((ip.strip(), 80))
|
||||||
|
sock.sendall(req)
|
||||||
|
data = b""
|
||||||
|
while True:
|
||||||
|
chunk = sock.recv(1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
first_line = data.split(b"\r\n", 1)[0] if data else b""
|
||||||
|
return b" 2" in first_line
|
||||||
|
|
||||||
|
|
||||||
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
||||||
@@ -292,10 +323,7 @@ async def identify_device(request, id):
|
|||||||
@controller.post("/<id>/patterns/push")
|
@controller.post("/<id>/patterns/push")
|
||||||
async def push_patterns_ota(request, id):
|
async def push_patterns_ota(request, id):
|
||||||
"""
|
"""
|
||||||
Ask a Wi-Fi LED driver to pull pattern files from this server over HTTP.
|
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
|
||||||
|
|
||||||
Body (optional):
|
|
||||||
{"manifest": "http://host:port/patterns/ota/manifest"}
|
|
||||||
"""
|
"""
|
||||||
dev = devices.read(id)
|
dev = devices.read(id)
|
||||||
if not dev:
|
if not dev:
|
||||||
@@ -312,31 +340,54 @@ async def push_patterns_ota(request, id):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
body = request.json or {}
|
base_dir = driver_patterns_dir()
|
||||||
manifest_payload = body.get("manifest")
|
try:
|
||||||
if manifest_payload is None:
|
names = sorted(os.listdir(base_dir))
|
||||||
host = request.headers.get("Host", "")
|
except OSError as e:
|
||||||
if not host:
|
return json.dumps({"error": str(e)}), 500, {
|
||||||
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",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
msg = json.dumps({"v": "1", "manifest": manifest_payload}, separators=(",", ":"))
|
files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
|
||||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
if not files:
|
||||||
if not ok:
|
return json.dumps({"error": "No pattern files found"}), 404, {
|
||||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, {
|
|
||||||
|
sent = []
|
||||||
|
failed = []
|
||||||
|
total = len(files)
|
||||||
|
for idx, filename in enumerate(files):
|
||||||
|
path = os.path.join(base_dir, filename)
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
code = f.read()
|
||||||
|
except OSError:
|
||||||
|
failed.append(filename)
|
||||||
|
continue
|
||||||
|
reload_patterns = idx == (total - 1)
|
||||||
|
ok = _http_post_pattern_source(
|
||||||
|
wifi_ip,
|
||||||
|
filename,
|
||||||
|
code,
|
||||||
|
reload_patterns=reload_patterns,
|
||||||
|
timeout_s=10.0,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
sent.append(filename)
|
||||||
|
else:
|
||||||
|
failed.append(filename)
|
||||||
|
|
||||||
|
if not sent:
|
||||||
|
return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"message": "Pattern files uploaded",
|
||||||
|
"sent_count": len(sent),
|
||||||
|
"sent": sent,
|
||||||
|
"failed": failed,
|
||||||
|
}), 200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|||||||
189
src/controllers/led_tool.py
Normal file
189
src/controllers/led_tool.py
Normal file
@@ -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
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
from models.pattern import Pattern
|
from models.pattern import Pattern
|
||||||
from models.device import Device
|
from models.device import Device
|
||||||
from models.wifi_ws_clients import send_json_line_to_ip
|
|
||||||
from util.driver_patterns import (
|
from util.driver_patterns import (
|
||||||
driver_patterns_dir,
|
driver_patterns_dir,
|
||||||
is_firmware_builtin_pattern_module,
|
is_firmware_builtin_pattern_module,
|
||||||
@@ -9,8 +8,9 @@ from util.driver_patterns import (
|
|||||||
)
|
)
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
patterns = Pattern()
|
patterns = Pattern()
|
||||||
@@ -48,6 +48,52 @@ def _normalize_pattern_key(raw):
|
|||||||
def _valid_pattern_key(key):
|
def _valid_pattern_key(key):
|
||||||
return bool(key and _PATTERN_KEY_RE.match(key))
|
return bool(key and _PATTERN_KEY_RE.match(key))
|
||||||
|
|
||||||
|
|
||||||
|
def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, timeout_s=10.0):
|
||||||
|
"""POST source to driver /patterns/upload?name=...&reload=...; return True on 2xx."""
|
||||||
|
if not isinstance(ip, str) or not ip.strip():
|
||||||
|
return False
|
||||||
|
if not isinstance(filename, str) or not filename:
|
||||||
|
return False
|
||||||
|
if not isinstance(code_text, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
name_q = quote(filename, safe="")
|
||||||
|
reload_q = "1" if reload_patterns else "0"
|
||||||
|
path = "/patterns/upload?name=%s&reload=%s" % (name_q, reload_q)
|
||||||
|
body = code_text.encode("utf-8")
|
||||||
|
req = (
|
||||||
|
"POST %s HTTP/1.1\r\n"
|
||||||
|
"Host: %s\r\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\r\n"
|
||||||
|
"Content-Length: %d\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"\r\n" % (path, ip, len(body))
|
||||||
|
).encode("utf-8") + body
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
sock.settimeout(timeout_s)
|
||||||
|
sock.connect((ip.strip(), 80))
|
||||||
|
sock.sendall(req)
|
||||||
|
data = b""
|
||||||
|
while True:
|
||||||
|
chunk = sock.recv(1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
first_line = data.split(b"\r\n", 1)[0] if data else b""
|
||||||
|
# Accept any 2xx status.
|
||||||
|
return b" 2" in first_line
|
||||||
|
|
||||||
def load_pattern_definitions():
|
def load_pattern_definitions():
|
||||||
"""Load pattern definitions from pattern.json file."""
|
"""Load pattern definitions from pattern.json file."""
|
||||||
try:
|
try:
|
||||||
@@ -170,7 +216,7 @@ async def ota_pattern_file(request, name):
|
|||||||
|
|
||||||
@controller.post('/<name>/send')
|
@controller.post('/<name>/send')
|
||||||
async def send_pattern_to_device(request, name):
|
async def send_pattern_to_device(request, name):
|
||||||
"""Tell Wi-Fi driver(s) to download one pattern source file over HTTP."""
|
"""Push one pattern source file directly to Wi-Fi driver(s) over HTTP."""
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
return json.dumps({"error": "Invalid pattern name"}), 400, {
|
return json.dumps({"error": "Invalid pattern name"}), 400, {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -183,7 +229,7 @@ async def send_pattern_to_device(request, name):
|
|||||||
if is_firmware_builtin_pattern_module(filename):
|
if is_firmware_builtin_pattern_module(filename):
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"error": "on and off are built into the driver firmware; OTA send does not apply.",
|
"error": "on and off are built into the driver firmware; send does not apply.",
|
||||||
}
|
}
|
||||||
), 400, {
|
), 400, {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -206,22 +252,11 @@ async def send_pattern_to_device(request, name):
|
|||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
file_url = "/patterns/ota/file/%s" % filename
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
msg = json.dumps(
|
source = f.read()
|
||||||
{
|
except OSError as e:
|
||||||
"v": "1",
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
"manifest": {
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": filename,
|
|
||||||
"url": file_url,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
target_ids = []
|
target_ids = []
|
||||||
if requested_device_id:
|
if requested_device_id:
|
||||||
dev = devices.read(requested_device_id)
|
dev = devices.read(requested_device_id)
|
||||||
@@ -250,12 +285,12 @@ async def send_pattern_to_device(request, name):
|
|||||||
ip = str(dev.get("address") or "").strip()
|
ip = str(dev.get("address") or "").strip()
|
||||||
if not ip:
|
if not ip:
|
||||||
continue
|
continue
|
||||||
ok = await send_json_line_to_ip(ip, msg)
|
ok = _http_post_pattern_source(ip, filename, source, reload_patterns=True, timeout_s=10.0)
|
||||||
if ok:
|
if ok:
|
||||||
sent_ids.append(did)
|
sent_ids.append(did)
|
||||||
|
|
||||||
if not sent_ids:
|
if not sent_ids:
|
||||||
return json.dumps({"error": "No Wi-Fi drivers connected"}), 503, {
|
return json.dumps({"error": "No Wi-Fi drivers accepted pattern upload"}), 503, {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
|
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ async def create_profile(request):
|
|||||||
"auto": True,
|
"auto": True,
|
||||||
"n1": 2,
|
"n1": 2,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Colour Cycle",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "transition",
|
"name": "transition",
|
||||||
"pattern": "transition",
|
"pattern": "transition",
|
||||||
@@ -132,6 +141,39 @@ async def create_profile(request):
|
|||||||
"delay": 500,
|
"delay": 500,
|
||||||
"auto": True,
|
"auto": True,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "flicker",
|
||||||
|
"pattern": "flicker",
|
||||||
|
"colors": ["#FFB84D"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 80,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flame",
|
||||||
|
"pattern": "flame",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 50,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 35,
|
||||||
|
"n2": 2600,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "twinkle",
|
||||||
|
"pattern": "twinkle",
|
||||||
|
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 55,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 72,
|
||||||
|
"n2": 140,
|
||||||
|
"n3": 2,
|
||||||
|
"n4": 6,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for preset_data in default_preset_defs:
|
for preset_data in default_preset_defs:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import controllers.scene as scene
|
|||||||
import controllers.pattern as pattern
|
import controllers.pattern as pattern
|
||||||
import controllers.settings as settings_controller
|
import controllers.settings as settings_controller
|
||||||
import controllers.device as device_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.transport import get_sender, set_sender, get_current_sender
|
||||||
from models.device import Device, normalize_mac
|
from models.device import Device, normalize_mac
|
||||||
from models import wifi_ws_clients as tcp_client_registry
|
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(pattern.controller, '/patterns')
|
||||||
app.mount(settings_controller.controller, '/settings')
|
app.mount(settings_controller.controller, '/settings')
|
||||||
app.mount(device_controller.controller, '/devices')
|
app.mount(device_controller.controller, '/devices')
|
||||||
|
app.mount(led_tool_controller.controller, '/led-tool')
|
||||||
|
|
||||||
tcp_client_registry.set_settings(settings)
|
tcp_client_registry.set_settings(settings)
|
||||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||||
|
|||||||
255
src/static/led_tool.js
Normal file
255
src/static/led_tool.js
Normal file
@@ -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 = '<option value="">Select a serial port</option>';
|
||||||
|
|
||||||
|
for (const port of data.ports || []) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = port.device;
|
||||||
|
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
|
||||||
|
portSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
if (previous) {
|
||||||
|
portSelect.value = previous;
|
||||||
|
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
|
||||||
|
portSelect.value = defaultPort;
|
||||||
|
} else {
|
||||||
|
const fallback = document.createElement('option');
|
||||||
|
fallback.value = defaultPort;
|
||||||
|
fallback.textContent = `${defaultPort} - default`;
|
||||||
|
portSelect.appendChild(fallback);
|
||||||
|
portSelect.value = defaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.led_cli_exists) {
|
||||||
|
showMessage('led-tool/cli.py was not found on the host.', 'error');
|
||||||
|
} else if ((data.ports || []).length === 0) {
|
||||||
|
showMessage('No serial ports found.', 'error');
|
||||||
|
} else {
|
||||||
|
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', () => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -237,6 +237,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
|
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/patterns/definitions', {
|
const response = await fetch('/patterns/definitions', {
|
||||||
|
cache: 'no-store',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -376,6 +377,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/patterns', {
|
const response = await fetch('/patterns', {
|
||||||
|
cache: 'no-store',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const presetsCloseButton = document.getElementById('presets-close-btn');
|
const presetsCloseButton = document.getElementById('presets-close-btn');
|
||||||
const presetsList = document.getElementById('presets-list');
|
const presetsList = document.getElementById('presets-list');
|
||||||
const presetsAddButton = document.getElementById('preset-add-btn');
|
const presetsAddButton = document.getElementById('preset-add-btn');
|
||||||
|
const presetClearDeviceButton = document.getElementById('preset-clear-device-btn');
|
||||||
const presetEditorModal = document.getElementById('preset-editor-modal');
|
const presetEditorModal = document.getElementById('preset-editor-modal');
|
||||||
const presetEditorCloseButton = document.getElementById('preset-editor-close-btn');
|
const presetEditorCloseButton = document.getElementById('preset-editor-close-btn');
|
||||||
const presetNameInput = document.getElementById('preset-name-input');
|
const presetNameInput = document.getElementById('preset-name-input');
|
||||||
@@ -283,7 +284,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!input) {
|
if (!input) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return parseInt(input.value, 10) || 0;
|
const n = parseInt(String(input.value).trim(), 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPresetColors = (colors, paletteRefs) => {
|
const renderPresetColors = (colors, paletteRefs) => {
|
||||||
@@ -564,14 +566,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const nKey = `n${i}`;
|
const nKey = `n${i}`;
|
||||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||||
if (inputEl) {
|
if (inputEl) {
|
||||||
if (preset[nKey] !== undefined) {
|
if (preset[nKey] !== undefined && preset[nKey] !== null) {
|
||||||
inputEl.value = preset[nKey] || 0;
|
const raw = preset[nKey];
|
||||||
|
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
|
||||||
|
inputEl.value = String(Number.isFinite(n) ? n : 0);
|
||||||
} else {
|
} else {
|
||||||
const label = nToLabel[nKey];
|
const label = nToLabel[nKey];
|
||||||
if (label && preset[label] !== undefined) {
|
if (label && preset[label] !== undefined && preset[label] !== null) {
|
||||||
inputEl.value = preset[label] || 0;
|
const rawL = preset[label];
|
||||||
|
const nL = typeof rawL === 'number' ? rawL : parseInt(String(rawL), 10);
|
||||||
|
inputEl.value = String(Number.isFinite(nL) ? nL : 0);
|
||||||
} else {
|
} else {
|
||||||
inputEl.value = 0;
|
inputEl.value = '0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -720,6 +726,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Load pattern definitions from pattern.json
|
// Load pattern definitions from pattern.json
|
||||||
let patternsPayload = null;
|
let patternsPayload = null;
|
||||||
let response = await fetch('/patterns/definitions', {
|
let response = await fetch('/patterns/definitions', {
|
||||||
|
cache: 'no-store',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -730,6 +737,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!Object.keys(normalized).length) {
|
if (!Object.keys(normalized).length) {
|
||||||
// Fallback when definitions route is unavailable or returns an empty map.
|
// Fallback when definitions route is unavailable or returns an empty map.
|
||||||
response = await fetch('/patterns', {
|
response = await fetch('/patterns', {
|
||||||
|
cache: 'no-store',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -809,6 +817,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPatternMeta =
|
||||||
|
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
|
||||||
|
const hasAnyNLabel = visibleNKeys.size > 0;
|
||||||
|
|
||||||
for (let i = 1; i <= 8; i++) {
|
for (let i = 1; i <= 8; i++) {
|
||||||
const nKey = `n${i}`;
|
const nKey = `n${i}`;
|
||||||
const labelEl = document.getElementById(`preset-${nKey}-label`);
|
const labelEl = document.getElementById(`preset-${nKey}-label`);
|
||||||
@@ -822,7 +834,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (groupEl) {
|
if (groupEl) {
|
||||||
groupEl.style.display = show ? '' : 'none';
|
groupEl.style.display = show ? '' : 'none';
|
||||||
}
|
}
|
||||||
if (inputEl && !show) {
|
// Only clear hidden n inputs when we know this pattern's metadata (avoids wiping n3..n4
|
||||||
|
// while definitions are still loading, or when twinkle exists only as a driver file).
|
||||||
|
if (inputEl && !show && (hasAnyNLabel || hasPatternMeta)) {
|
||||||
inputEl.value = '0';
|
inputEl.value = '0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -967,6 +981,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
openEditor();
|
openEditor();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (presetClearDeviceButton) {
|
||||||
|
presetClearDeviceButton.addEventListener('click', async () => {
|
||||||
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
|
if (!deviceNames.length) {
|
||||||
|
alert('No devices found in the current zone.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm('Clear all presets on current zone devices?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const targetMacs =
|
||||||
|
typeof window.tabsManager !== 'undefined' &&
|
||||||
|
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||||
|
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||||||
|
: [];
|
||||||
|
await postDriverSequence([{ v: '1', clear_presets: true, save: true }], targetMacs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clear device presets failed:', error);
|
||||||
|
alert('Failed to clear presets on devices.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const showAddPresetToTabModal = async (optionalTabId) => {
|
const showAddPresetToTabModal = async (optionalTabId) => {
|
||||||
let zoneId = optionalTabId;
|
let zoneId = optionalTabId;
|
||||||
@@ -1341,26 +1379,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error('Failed to save preset');
|
throw new Error('Failed to save preset');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same device targeting as Try: zone tab supplies names → /presets/push gets targets + select.
|
||||||
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
|
|
||||||
// Use saved preset from server response for sending
|
// Use saved preset from server response for sending
|
||||||
const saved = await response.json().catch(() => null);
|
const saved = await response.json().catch(() => null);
|
||||||
if (saved && typeof saved === 'object') {
|
if (saved && typeof saved === 'object') {
|
||||||
if (currentEditId) {
|
if (currentEditId) {
|
||||||
// PUT returns the preset object directly; use the existing ID
|
// PUT returns the preset object directly; use the existing ID
|
||||||
// Save & Send should not force-select the preset on devices.
|
await sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false);
|
||||||
await sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
|
||||||
} else {
|
} else {
|
||||||
// POST returns { id: preset }
|
// POST returns { id: preset }
|
||||||
const entries = Object.entries(saved);
|
const entries = Object.entries(saved);
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
const [newId, presetData] = entries[0];
|
const [newId, presetData] = entries[0];
|
||||||
// Save & Send should not force-select the preset on devices.
|
await sendPresetViaEspNow(newId, presetData, deviceNames, true, false);
|
||||||
await sendPresetViaEspNow(newId, presetData, [], true, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: send what we just built
|
// Fallback: send what we just built
|
||||||
// Save & Send should not force-select the preset on devices.
|
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, true, false);
|
||||||
await sendPresetViaEspNow(payload.name, payload, [], true, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadPresets();
|
await loadPresets();
|
||||||
@@ -1402,6 +1441,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearForm();
|
clearForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const coercePresetInt = (v, def = 0) => {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
const t = parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(t) ? t : def;
|
||||||
|
};
|
||||||
|
|
||||||
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
|
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
|
||||||
// Send order:
|
// Send order:
|
||||||
// 1) preset payload (optionally with save)
|
// 1) preset payload (optionally with save)
|
||||||
@@ -1426,12 +1473,12 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
|||||||
? preset.brightness
|
? preset.brightness
|
||||||
: (typeof preset.br === 'number' ? preset.br : 127),
|
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||||
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
||||||
n1: typeof preset.n1 === 'number' ? preset.n1 : 0,
|
n1: coercePresetInt(preset.n1),
|
||||||
n2: typeof preset.n2 === 'number' ? preset.n2 : 0,
|
n2: coercePresetInt(preset.n2),
|
||||||
n3: typeof preset.n3 === 'number' ? preset.n3 : 0,
|
n3: coercePresetInt(preset.n3),
|
||||||
n4: typeof preset.n4 === 'number' ? preset.n4 : 0,
|
n4: coercePresetInt(preset.n4),
|
||||||
n5: typeof preset.n5 === 'number' ? preset.n5 : 0,
|
n5: coercePresetInt(preset.n5),
|
||||||
n6: typeof preset.n6 === 'number' ? preset.n6 : 0,
|
n6: coercePresetInt(preset.n6),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1253,24 +1253,32 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Preset editor: brightness/delay field wrappers */
|
/* Preset editor: brightness/delay field wrappers */
|
||||||
.preset-editor-field {
|
#preset-editor-modal .preset-editor-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 10rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-editor-field label {
|
#preset-editor-modal .preset-editor-field label {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-editor-field input[type="number"] {
|
#preset-editor-modal .preset-editor-field input[type="number"] {
|
||||||
width: var(--n-input-width, 5ch);
|
width: 100%;
|
||||||
max-width: 100%;
|
min-width: 5.5rem;
|
||||||
|
max-width: 7rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preset editor n-parameter inputs need extra room for values + spinner controls. */
|
||||||
|
#preset-editor-modal .n-input {
|
||||||
|
width: 6.5ch;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pattern editor: numeric metadata row */
|
/* Pattern editor: numeric metadata row */
|
||||||
#pattern-editor-modal input[type="number"] {
|
#pattern-editor-modal input[type="number"] {
|
||||||
width: var(--n-input-width, 5ch);
|
width: var(--n-input-width, 5ch);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
|
||||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||||
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
|
||||||
<button type="button" data-target="help-btn">Help</button>
|
<button type="button" data-target="help-btn">Help</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +160,7 @@
|
|||||||
<h2>Presets</h2>
|
<h2>Presets</h2>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
||||||
|
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="presets-list" class="profiles-list"></div>
|
<div id="presets-list" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -364,6 +367,13 @@
|
|||||||
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>What led-tool does</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
|
||||||
|
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
|
||||||
|
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,9 +448,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- LED Tool Modal -->
|
||||||
|
<div id="led-tool-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>LED Tool (USB)</h2>
|
||||||
|
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p>
|
||||||
|
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div>
|
||||||
|
<form id="led-tool-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="led-tool-port">Serial port</label>
|
||||||
|
<div class="profiles-actions" style="gap: 0.5rem;">
|
||||||
|
<select id="led-tool-port" required style="flex:1;">
|
||||||
|
<option value="">Select a serial port</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="led-tool-name">Name</label>
|
||||||
|
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
|
||||||
|
</div>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="led-tool-num-leds">Num LEDs</label>
|
||||||
|
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="led-tool-led-pin">LED pin</label>
|
||||||
|
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="led-tool-brightness">Brightness</label>
|
||||||
|
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="led-tool-wifi-channel">WiFi channel</label>
|
||||||
|
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="led-tool-transport">Transport</label>
|
||||||
|
<select id="led-tool-transport">
|
||||||
|
<option value="">(no change)</option>
|
||||||
|
<option value="espnow">espnow</option>
|
||||||
|
<option value="wifi">wifi</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="led-tool-default">Default preset</label>
|
||||||
|
<input type="text" id="led-tool-default" placeholder="on">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="led-tool-ssid">SSID</label>
|
||||||
|
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="led-tool-password">WiFi password</label>
|
||||||
|
<input type="password" id="led-tool-password" placeholder="WiFi password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Apply via USB</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
|
||||||
|
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Styles moved to /static/style.css -->
|
<!-- Styles moved to /static/style.css -->
|
||||||
<script src="/static/zones.js"></script>
|
<script src="/static/zones.js"></script>
|
||||||
<script src="/static/help.js"></script>
|
<script src="/static/help.js"></script>
|
||||||
|
<script src="/static/led_tool.js"></script>
|
||||||
<script src="/static/color_palette.js"></script>
|
<script src="/static/color_palette.js"></script>
|
||||||
<script src="/static/profiles.js"></script>
|
<script src="/static/profiles.js"></script>
|
||||||
<script src="/static/zone_palette.js"></script>
|
<script src="/static/zone_palette.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user