feat(patterns,api): pattern OTA, graceful shutdown, driver delivery updates
- Pattern controller/UI and presets patterns tab for OTA to Wi-Fi drivers - Device controller extensions; driver_delivery chunk handling - main: SIGINT/SIGTERM shutdown, TCP/UDP server close coordination - Submodule led-driver: Wi-Fi default transport, lazy espnow import, dynamic patterns Made-with: Cursor
This commit is contained in:
@@ -14,6 +14,7 @@ from models.tcp_clients import (
|
||||
from util.espnow_message import build_message
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
|
||||
_IDENTIFY_PRESET_KEY = "__identify"
|
||||
@@ -72,6 +73,37 @@ def _device_json_with_live_status(dev_dict):
|
||||
return row
|
||||
|
||||
|
||||
def _driver_patterns_dir():
|
||||
here = os.path.dirname(__file__)
|
||||
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
|
||||
|
||||
|
||||
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 _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}
|
||||
|
||||
|
||||
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
||||
try:
|
||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||
@@ -259,3 +291,56 @@ async def identify_device(request, id):
|
||||
return json.dumps({"message": "Identify sent"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/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"}
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
wifi_ip = str(dev.get("address") or "").strip()
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"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, {
|
||||
"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, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user