From 35730b36f01c9efd847493200258d38e8b8f0ba2 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 19 Apr 2026 23:28:01 +1200 Subject: [PATCH] feat(api): improve pattern deploy and device tcp handling Made-with: Cursor --- src/controllers/device.py | 129 ++++++++++++++++++++++++++----------- src/controllers/pattern.py | 79 ++++++++++++++++------- 2 files changed, 147 insertions(+), 61 deletions(-) diff --git a/src/controllers/device.py b/src/controllers/device.py index 935ff12..cafc75a 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -16,6 +16,8 @@ from util.espnow_message import build_message import asyncio import json import os +import socket +from urllib.parse import quote # Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire). _IDENTIFY_PRESET_KEY = "__identify" @@ -84,20 +86,49 @@ def _safe_pattern_filename(name): 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} +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"" + return b" 2" in first_line 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("//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"} + Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload. """ dev = devices.read(id) if not dev: @@ -312,31 +340,54 @@ async def push_patterns_ota(request, id): "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, { + base_dir = driver_patterns_dir() + try: + names = sorted(os.listdir(base_dir)) + except OSError as e: + return json.dumps({"error": str(e)}), 500, { "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, { + files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"] + if not files: + return json.dumps({"error": "No pattern files found"}), 404, { "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", } diff --git a/src/controllers/pattern.py b/src/controllers/pattern.py index f6ed694..8d25199 100644 --- a/src/controllers/pattern.py +++ b/src/controllers/pattern.py @@ -1,7 +1,6 @@ from microdot import Microdot from models.pattern import Pattern from models.device import Device -from models.wifi_ws_clients import send_json_line_to_ip from util.driver_patterns import ( driver_patterns_dir, is_firmware_builtin_pattern_module, @@ -9,8 +8,9 @@ from util.driver_patterns import ( ) import json import re -import sys import os +import socket +from urllib.parse import quote controller = Microdot() patterns = Pattern() @@ -48,6 +48,52 @@ def _normalize_pattern_key(raw): def _valid_pattern_key(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(): """Load pattern definitions from pattern.json file.""" try: @@ -170,7 +216,7 @@ async def ota_pattern_file(request, name): @controller.post('//send') 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): return json.dumps({"error": "Invalid pattern name"}), 400, { "Content-Type": "application/json" @@ -183,7 +229,7 @@ async def send_pattern_to_device(request, name): if is_firmware_builtin_pattern_module(filename): 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, { "Content-Type": "application/json" @@ -206,22 +252,11 @@ async def send_pattern_to_device(request, name): "Content-Type": "application/json" } - file_url = "/patterns/ota/file/%s" % filename - - msg = json.dumps( - { - "v": "1", - "manifest": { - "files": [ - { - "name": filename, - "url": file_url, - } - ] - }, - }, - separators=(",", ":"), - ) + try: + with open(path, "r") as f: + source = f.read() + except OSError as e: + return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} target_ids = [] if 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() if not ip: 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: sent_ids.append(did) 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" } return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {