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:
pi
2026-04-11 15:10:23 +12:00
parent 7179b6531e
commit e67de6215a
9 changed files with 1031 additions and 67 deletions

View File

@@ -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",
}

View File

@@ -1,19 +1,67 @@
from microdot import Microdot
from models.pattern import Pattern
from models.device import Device
from models.tcp_clients import send_json_line_to_ip
import json
import re
import sys
import os
controller = Microdot()
patterns = Pattern()
def _project_root():
"""Project root (parent of ``src/``). CWD is often ``src/`` when running ``main.py``."""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.abspath(os.path.join(here, "..", ".."))
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
_PATTERN_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
def _normalize_pattern_key(raw):
"""Pattern id / module basename (no .py)."""
if not isinstance(raw, str):
return ""
s = raw.strip()
if s.lower().endswith(".py"):
s = s[:-3].strip()
return s
def _valid_pattern_key(key):
return bool(key and _PATTERN_KEY_RE.match(key))
def load_pattern_definitions():
"""Load pattern definitions from pattern.json file."""
try:
# Try different paths for local development vs MicroPython
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
root = _project_root()
paths = [
os.path.join(root, "db", "pattern.json"),
os.path.join(root, "pattern.json"),
"db/pattern.json",
"pattern.json",
"/db/pattern.json",
]
for path in paths:
try:
with open(path, 'r') as f:
with open(path, "r") as f:
return json.load(f)
except OSError:
continue
@@ -22,16 +70,301 @@ def load_pattern_definitions():
print(f"Error loading pattern.json: {e}")
return {}
def load_driver_pattern_names():
"""List available pattern module names from led-driver/src/patterns."""
try:
names = []
for filename in os.listdir(_driver_patterns_dir()):
if not _safe_pattern_filename(filename) or filename == "__init__.py":
continue
names.append(filename[:-3])
names.sort()
return names
except OSError:
return []
def build_runtime_pattern_map():
"""
Runtime pattern map for UI menus.
Keep pattern DB metadata as primary, then add any local driver pattern files
missing from the DB so new OTA files still appear in menus.
"""
definitions = load_pattern_definitions()
available = load_driver_pattern_names()
result = {}
for name, meta in definitions.items():
result[name] = dict(meta) if isinstance(meta, dict) else {}
for name in available:
if name not in result:
result[name] = {}
return result
@controller.get('/definitions')
async def get_pattern_definitions(request):
"""Get pattern definitions from pattern.json."""
definitions = load_pattern_definitions()
"""Get definitions for patterns currently available on the driver."""
definitions = build_runtime_pattern_map()
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
@controller.get('/ota/manifest')
async def ota_manifest(request):
"""Manifest of driver pattern source files for OTA pulls."""
base_dir = _driver_patterns_dir()
host = request.headers.get("Host", "")
if not host:
return json.dumps({"error": "Missing Host header"}), 400, {
"Content-Type": "application/json"
}
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
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 json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
@controller.get('/ota/file/<name>')
async def ota_pattern_file(request, name):
"""Serve one driver pattern source file for OTA pulls."""
if not _safe_pattern_filename(name) or name == "__init__.py":
return json.dumps({"error": "Invalid filename"}), 400, {
"Content-Type": "application/json"
}
path = os.path.join(_driver_patterns_dir(), name)
try:
with open(path, "r") as f:
content = f.read()
except OSError:
return json.dumps({"error": "Pattern file not found"}), 404, {
"Content-Type": "application/json"
}
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
@controller.post('/<name>/send')
async def send_pattern_to_device(request, name):
"""Tell Wi-Fi driver(s) to download one pattern source file over HTTP."""
if not isinstance(name, str):
return json.dumps({"error": "Invalid pattern name"}), 400, {
"Content-Type": "application/json"
}
filename = name if name.endswith(".py") else (name + ".py")
if not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "Invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
devices = Device()
body = request.json or {}
requested_device_id = str(body.get("device_id") or "").strip()
path = os.path.join(_driver_patterns_dir(), filename)
if not os.path.exists(path):
return json.dumps({"error": "Pattern file not found"}), 404, {
"Content-Type": "application/json"
}
file_url = "/patterns/ota/file/%s" % filename
msg = json.dumps(
{
"v": "1",
"manifest": {
"files": [
{
"name": filename,
"url": file_url,
}
]
},
},
separators=(",", ":"),
)
target_ids = []
if requested_device_id:
dev = devices.read(requested_device_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 send is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json"
}
target_ids = [requested_device_id]
else:
for did in devices.list():
dev = devices.read(did) or {}
if (dev.get("transport") or "").lower() == "wifi":
target_ids.append(str(did))
if not target_ids:
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
"Content-Type": "application/json"
}
sent_ids = []
for did in target_ids:
dev = devices.read(did) or {}
ip = str(dev.get("address") or "").strip()
if not ip:
continue
ok = await send_json_line_to_ip(ip, msg)
if ok:
sent_ids.append(did)
if not sent_ids:
return json.dumps({"error": "No Wi-Fi drivers connected"}), 503, {
"Content-Type": "application/json"
}
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
"Content-Type": "application/json"
}
@controller.post('/upload')
async def upload_pattern_file(request):
"""
Upload a pattern source file to led-controller local storage.
Body JSON:
{
"name": "sparkle.py" | "sparkle",
"code": "class Sparkle: ...",
"overwrite": true | false # optional, default true
}
"""
data = request.json or {}
raw_name = data.get("name") or data.get("filename")
code = data.get("code")
overwrite = data.get("overwrite", True)
overwrite = bool(overwrite)
if not isinstance(raw_name, str) or not raw_name.strip():
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json"
}
filename = raw_name.strip()
if not filename.endswith(".py"):
filename += ".py"
if not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required"}), 400, {
"Content-Type": "application/json"
}
path = os.path.join(_driver_patterns_dir(), filename)
exists = os.path.exists(path)
if exists and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
try:
with open(path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return json.dumps({
"message": "Pattern uploaded",
"name": filename,
"overwrote": bool(exists),
}), 201, {"Content-Type": "application/json"}
@controller.post('/driver')
async def create_driver_pattern(request):
"""
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
metadata in db/pattern.json (Pattern model).
Body JSON:
name, code (required),
min_delay, max_delay, max_colors (optional numbers),
n1..n8 (optional string labels),
overwrite (optional, default true).
"""
data = request.json or {}
key = _normalize_pattern_key(data.get("name") or "")
if not _valid_pattern_key(key):
return json.dumps({
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
}), 400, {"Content-Type": "application/json"}
code = data.get("code")
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
"Content-Type": "application/json"
}
overwrite = bool(data.get("overwrite", True))
filename = key + ".py"
py_path = os.path.join(_driver_patterns_dir(), filename)
if os.path.exists(py_path) and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
meta = {}
for fld in ("min_delay", "max_delay", "max_colors"):
if fld not in data:
continue
try:
meta[fld] = int(data[fld])
except (TypeError, ValueError):
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
"Content-Type": "application/json"
}
for i in range(1, 9):
nk = "n%d" % i
if nk not in data:
continue
lab = data[nk]
if lab is None:
continue
s = str(lab).strip()
if s:
meta[nk] = s
try:
with open(py_path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
if patterns.read(key):
patterns.update(key, meta)
else:
patterns.create(key, meta)
return json.dumps({
"message": "Pattern created",
"name": key,
"file": filename,
"metadata": patterns.read(key),
}), 201, {"Content-Type": "application/json"}
@controller.get('')
async def list_patterns(request):
"""List all patterns."""
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
"""List patterns for UI (DB metadata + local driver additions)."""
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')