252 lines
7.9 KiB
Python
252 lines
7.9 KiB
Python
"""Parse controller JSON (v1) and apply brightness, presets, OTA patterns, etc."""
|
|
|
|
import json
|
|
import socket
|
|
|
|
from binary_envelope import parse_binary_envelope
|
|
from utils import convert_and_reorder_colors
|
|
|
|
try:
|
|
import uos as os
|
|
except ImportError:
|
|
import os
|
|
|
|
|
|
def process_data(payload, settings, presets, controller_ip=None):
|
|
"""Read one controller message; binary v1 envelope or JSON v1, then apply fields."""
|
|
data = None
|
|
if isinstance(payload, (bytes, bytearray)):
|
|
data = parse_binary_envelope(payload)
|
|
if data is None:
|
|
try:
|
|
data = json.loads(payload)
|
|
except (ValueError, TypeError):
|
|
return
|
|
else:
|
|
try:
|
|
data = json.loads(payload)
|
|
except (ValueError, TypeError):
|
|
return
|
|
print(payload)
|
|
if data.get("v", "") != "1":
|
|
return
|
|
if "b" in data:
|
|
apply_brightness(data, settings, presets)
|
|
if "presets" in data:
|
|
apply_presets(data, settings, presets)
|
|
if "clear_presets" in data:
|
|
apply_clear_presets(data, presets)
|
|
if "select" in data:
|
|
apply_select(data, settings, presets)
|
|
if "default" in data:
|
|
apply_default(data, settings, presets)
|
|
if "manifest" in data:
|
|
apply_patterns_ota(data, presets, controller_ip=controller_ip)
|
|
if "save" in data and ("presets" in data or "default" in data):
|
|
presets.save()
|
|
if "save" in data and "clear_presets" in data:
|
|
presets.save()
|
|
if "save" in data and "b" in data:
|
|
settings.save()
|
|
|
|
|
|
def apply_brightness(data, settings, presets):
|
|
try:
|
|
presets.b = max(0, min(255, int(data["b"])))
|
|
settings["brightness"] = presets.b
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
|
|
def apply_presets(data, settings, presets):
|
|
presets_map = data["presets"]
|
|
for id, preset_data in presets_map.items():
|
|
if not preset_data:
|
|
continue
|
|
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
|
|
if color_key is not None:
|
|
try:
|
|
preset_data[color_key] = convert_and_reorder_colors(
|
|
preset_data[color_key], settings
|
|
)
|
|
except (TypeError, ValueError, KeyError):
|
|
continue
|
|
presets.edit(id, preset_data)
|
|
print(f"Edited preset {id}: {preset_data.get('name', '')}")
|
|
|
|
|
|
def apply_select(data, settings, presets):
|
|
select_map = data["select"]
|
|
device_name = settings["name"]
|
|
select_list = select_map.get(device_name, [])
|
|
if not select_list:
|
|
return
|
|
preset_name = select_list[0]
|
|
step = select_list[1] if len(select_list) > 1 else None
|
|
presets.select(preset_name, step=step)
|
|
|
|
|
|
def apply_clear_presets(data, presets):
|
|
clear_value = data.get("clear_presets")
|
|
if isinstance(clear_value, bool):
|
|
should_clear = clear_value
|
|
elif isinstance(clear_value, int):
|
|
should_clear = bool(clear_value)
|
|
elif isinstance(clear_value, str):
|
|
should_clear = clear_value.lower() in ("true", "1", "yes", "on")
|
|
else:
|
|
should_clear = False
|
|
if not should_clear:
|
|
return
|
|
presets.delete_all()
|
|
print("Cleared all presets.")
|
|
|
|
|
|
def apply_default(data, settings, presets):
|
|
targets = data.get("targets") or []
|
|
default_name = data["default"]
|
|
if (
|
|
settings["name"] in targets
|
|
and isinstance(default_name, str)
|
|
and default_name in presets.presets
|
|
):
|
|
settings["default"] = default_name
|
|
settings.save()
|
|
|
|
|
|
def _parse_http_url(url):
|
|
"""Parse http://host[:port]/path into (host, port, path)."""
|
|
if not isinstance(url, str):
|
|
raise ValueError("url must be a string")
|
|
if not url.startswith("http://"):
|
|
raise ValueError("only http:// URLs are supported")
|
|
remainder = url[7:]
|
|
slash_idx = remainder.find("/")
|
|
if slash_idx == -1:
|
|
host_port = remainder
|
|
path = "/"
|
|
else:
|
|
host_port = remainder[:slash_idx]
|
|
path = remainder[slash_idx:]
|
|
if ":" in host_port:
|
|
host, port_s = host_port.rsplit(":", 1)
|
|
port = int(port_s)
|
|
else:
|
|
host = host_port
|
|
port = 80
|
|
if not host:
|
|
raise ValueError("missing host")
|
|
return host, port, path
|
|
|
|
|
|
def _http_get_raw(url, timeout_s=10.0):
|
|
host, port, path = _parse_http_url(url)
|
|
req = (
|
|
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % (path, host)
|
|
).encode("utf-8")
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
try:
|
|
sock.settimeout(timeout_s)
|
|
sock.connect((host, int(port)))
|
|
sock.send(req)
|
|
data = b""
|
|
while True:
|
|
chunk = sock.recv(1024)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
finally:
|
|
try:
|
|
sock.close()
|
|
except Exception:
|
|
pass
|
|
sep = b"\r\n\r\n"
|
|
if sep not in data:
|
|
raise OSError("invalid HTTP response")
|
|
head, body = data.split(sep, 1)
|
|
status_line = head.split(b"\r\n", 1)[0]
|
|
if b" 200 " not in status_line:
|
|
raise OSError("HTTP status not OK: %s" % status_line.decode("utf-8"))
|
|
return body
|
|
|
|
|
|
def _http_get_json(url, timeout_s=10.0):
|
|
body = _http_get_raw(url, timeout_s=timeout_s)
|
|
return json.loads(body.decode("utf-8"))
|
|
|
|
|
|
def _http_get_text(url, timeout_s=10.0, controller_ip=None):
|
|
# Support relative URLs from controller messages.
|
|
if isinstance(url, str) and url.startswith("/"):
|
|
if not controller_ip:
|
|
raise OSError("controller IP unavailable for relative URL")
|
|
url = "http://%s%s" % (controller_ip, url)
|
|
try:
|
|
body = _http_get_raw(url, timeout_s=timeout_s)
|
|
return body.decode("utf-8")
|
|
except Exception:
|
|
# Fallback for mDNS/unresolvable host: retry against current controller IP.
|
|
if not controller_ip or not isinstance(url, str) or not url.startswith("http://"):
|
|
raise
|
|
_host, _port, path = _parse_http_url(url)
|
|
fallback = "http://%s:%d%s" % (controller_ip, _port, path)
|
|
body = _http_get_raw(fallback, timeout_s=timeout_s)
|
|
return body.decode("utf-8")
|
|
|
|
|
|
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 apply_patterns_ota(data, presets, controller_ip=None):
|
|
manifest_payload = data.get("manifest")
|
|
if not manifest_payload:
|
|
return
|
|
try:
|
|
if isinstance(manifest_payload, dict):
|
|
manifest = manifest_payload
|
|
elif isinstance(manifest_payload, str):
|
|
manifest = _http_get_json(manifest_payload, timeout_s=20.0)
|
|
else:
|
|
print("patterns_ota: invalid manifest payload type")
|
|
return
|
|
files = manifest.get("files", [])
|
|
if not isinstance(files, list) or not files:
|
|
print("patterns_ota: no files in manifest")
|
|
return
|
|
try:
|
|
os.mkdir("patterns")
|
|
except OSError:
|
|
pass
|
|
updated = 0
|
|
for item in files:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get("name")
|
|
url = item.get("url")
|
|
inline_code = item.get("code")
|
|
if not _safe_pattern_filename(name):
|
|
continue
|
|
if isinstance(inline_code, str):
|
|
code = inline_code
|
|
elif isinstance(url, str):
|
|
code = _http_get_text(url, timeout_s=20.0, controller_ip=controller_ip)
|
|
else:
|
|
continue
|
|
with open("patterns/" + name, "w") as f:
|
|
f.write(code)
|
|
updated += 1
|
|
if updated > 0:
|
|
presets.reload_patterns()
|
|
print("patterns_ota: updated", updated, "pattern file(s)")
|
|
else:
|
|
print("patterns_ota: no valid files downloaded")
|
|
except Exception as e:
|
|
print("patterns_ota failed:", e)
|