feat(api): improve pattern deploy and device tcp handling
Made-with: Cursor
This commit is contained in:
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user