feat(api): improve pattern deploy and device tcp handling

Made-with: Cursor
This commit is contained in:
2026-04-19 23:28:01 +12:00
parent d516833cc3
commit 35730b36f0
2 changed files with 147 additions and 61 deletions

View File

@@ -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")
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: try:
manifest_payload = _build_patterns_manifest(host) names = sorted(os.listdir(base_dir))
except OSError as e: except OSError as e:
return json.dumps({"error": str(e)}), 500, { return json.dumps({"error": str(e)}), 500, {
"Content-Type": "application/json", "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, { 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", "Content-Type": "application/json",
} }
msg = json.dumps({"v": "1", "manifest": manifest_payload}, separators=(",", ":")) sent = []
ok = await send_json_line_to_ip(wifi_ip, msg) failed = []
if not ok: total = len(files)
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, { 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", "Content-Type": "application/json",
} }
return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, {
return json.dumps({
"message": "Pattern files uploaded",
"sent_count": len(sent),
"sent": sent,
"failed": failed,
}), 200, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }

View File

@@ -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, {