Files
led-controller/src/controllers/device.py
Jimmy c64dd736f2 feat(api): parallel group sends and batch identify
- asyncio.gather for group brightness and driver-config Wi-Fi pushes
- Batch identify envelope for group members

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:13 +12:00

638 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from microdot import Microdot
from models.device import (
Device,
derive_device_mac,
normalize_mac,
validate_device_transport,
validate_device_type,
)
from models.group import Group
from models.transport import get_current_sender
from settings import Settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from util.driver_patterns import driver_patterns_dir
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"
# Short-key payload: 10 Hz full cycle = 50 ms on + 50 ms off (driver ``blink`` toggles each ``d`` ms).
_IDENTIFY_DRIVER_PRESET = {
"p": "blink",
"c": ["#ff0000"],
"d": 50,
"b": 128,
"a": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
}
def _compact_v1_json(*, presets=None, select=None, save=False):
"""Single-line v1 object; compact so serial/ESP-NOW stays small."""
body = {"v": "1"}
if presets is not None:
body["presets"] = presets
if save:
body["save"] = True
if select is not None:
body["select"] = select
return json.dumps(body, separators=(",", ":"))
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0
def _validate_output_brightness(value):
if value is None:
return None
try:
b = int(value)
except (TypeError, ValueError):
raise ValueError("output_brightness must be an integer 0255")
if b < 0 or b > 255:
raise ValueError("output_brightness must be between 0 and 255")
return b
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
controller = Microdot()
devices = Device()
_group_registry = Group()
_pi_settings = Settings()
def _device_live_connected(dev_dict):
"""
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
"""
tr = (dev_dict.get("transport") or "espnow").strip().lower()
if tr != "wifi":
return None
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
if not ip:
return False
return tcp_client_connected(ip)
def _device_json_with_live_status(dev_dict):
row = dict(dev_dict)
row["connected"] = _device_live_connected(dev_dict)
return row
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 _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):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
except Exception:
pass
async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
"""
Send the same identify blink as ``POST /devices/<id>/identify``.
Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure
(status matches the single-device route).
"""
dev = devices.read(dev_id)
if not dev:
return 404, "Device not found"
sender = get_current_sender()
if not sender:
return 503, "Transport not configured"
name = str(dev.get("name") or "").strip()
if not name:
return 400, "Device must have a name to identify"
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return 400, "Device has no IP address"
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return 503, "Wi-Fi driver not connected"
else:
await sender.send(msg, addr=dev_id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
)
except Exception as e:
return 503, str(e)
return 200, ""
async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]:
"""
Identify every listed registry MAC in one delivery round: merged ``select`` and a single
ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
``deliver_json_messages``.
"""
from util.driver_delivery import deliver_json_messages
errors: list[dict] = []
sender = get_current_sender()
if not sender:
return 0, [{"mac": "*", "error": "Transport not configured"}]
merged_select: dict[str, list[str]] = {}
valid_macs: list[str] = []
for dev_id in macs:
dev = devices.read(dev_id)
if not dev:
errors.append({"mac": dev_id, "error": "Device not found"})
continue
name = str(dev.get("name") or "").strip()
if not name:
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
continue
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
if not dev.get("address"):
errors.append({"mac": dev_id, "error": "Device has no IP address"})
continue
merged_select[name] = [_IDENTIFY_PRESET_KEY]
valid_macs.append(dev_id)
if not merged_select:
return 0, errors
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select=merged_select,
)
await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
except Exception as e:
return 0, errors + [{"mac": "*", "error": str(e)}]
for dev_id in valid_macs:
dev = devices.read(dev_id) or {}
name = str(dev.get("name") or "").strip()
transport = (dev.get("transport") or "espnow").strip().lower()
wifi_ip = dev.get("address") if transport == "wifi" else None
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
)
return len(valid_macs), errors
@controller.get("")
async def list_devices(request):
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = _device_json_with_live_status(d)
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.post("/resolve-brightness")
async def resolve_brightness_batch(request):
"""
POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0255 }``.
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
"""
try:
data = request.json or {}
except Exception:
data = {}
macs = data.get("macs")
if not isinstance(macs, list):
return json.dumps({"error": "macs must be an array"}), 400, {
"Content-Type": "application/json",
}
zb = None
if isinstance(data, dict) and data.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(data.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
values = {}
for raw in macs:
m = normalize_mac(str(raw))
if not m:
continue
values[m] = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
m,
zone_brightness=zb,
)
return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
dev = devices.read(id)
if dev:
return json.dumps(_device_json_with_live_status(dev)), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("")
async def create_device(request):
"""Create a new device."""
try:
data = request.json or {}
name = data.get("name", "").strip()
if not name:
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json",
}
try:
device_type = validate_device_type(data.get("type", "led"))
transport = validate_device_transport(data.get("transport", "espnow"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {
"Content-Type": "application/json",
}
address = data.get("address")
mac = data.get("mac")
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
return json.dumps(
{
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
}
), 400, {"Content-Type": "application/json"}
default_pattern = data.get("default_pattern")
zl = data.get("zones")
if isinstance(zl, list):
zl = [str(t) for t in zl]
else:
zl = []
dev_id = devices.create(
name=name,
address=address,
mac=mac,
default_pattern=default_pattern,
zones=zl,
device_type=device_type,
transport=transport,
)
dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
except ValueError as e:
msg = str(e)
code = 409 if "already exists" in msg.lower() else 400
return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>")
async def update_device(request, id):
"""Update a device."""
try:
raw = request.json or {}
data = dict(raw)
data.pop("id", None)
data.pop("addresses", None)
data.pop("connected", None)
if "name" in data:
n = (data.get("name") or "").strip()
if not n:
return json.dumps({"error": "name cannot be empty"}), 400, {
"Content-Type": "application/json",
}
data["name"] = n
if "type" in data:
data["type"] = validate_device_type(data.get("type"))
if "transport" in data:
data["transport"] = validate_device_transport(data.get("transport"))
if "zones" in data and isinstance(data["zones"], list):
data["zones"] = [str(t) for t in data["zones"]]
if "output_brightness" in data:
data["output_brightness"] = _validate_output_brightness(data.get("output_brightness"))
prev_doc = devices.read(id)
if devices.update(id, data):
if prev_doc and "name" in data:
on = str(prev_doc.get("name") or "").strip()
nn = str(data.get("name") or "").strip()
if on and nn and on != nn:
from util.beat_driver_route import remap_beat_route_device_name
remap_beat_route_device_name(on, nn)
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.delete("/<id>")
async def delete_device(request, id):
"""Delete a device."""
if devices.delete(id):
return (
json.dumps({"message": "Device deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("/<id>/identify")
async def identify_device(request, id):
"""
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
this device name — same combined shape as profile sends the driver already accepts over TCP
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
"""
status, err = await send_identify_to_device(id)
if status == 200:
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
@controller.post("/<id>/brightness")
async def push_device_output_brightness(request, id):
"""
Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
in JSON body — single ``b`` (``v``/``b``/``save``). WiFi or ESPNOW.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
body = request.json or {}
zb = None
if isinstance(body, dict) and body.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(body.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
b_val = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
id,
zone_brightness=zb,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
ok = await send_json_line_to_ip(ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
await sender.send(msg, addr=id)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/driver-config")
async def push_driver_config(request, id):
"""
Push ``device_config`` to a WiFi LED driver over WebSocket.
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
"""
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": "driver-config is only 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 {}
dc = {}
if isinstance(body.get("name"), str) and body["name"].strip():
dc["name"] = body["name"].strip()
if "num_leds" in body:
try:
n = int(body["num_leds"])
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
if isinstance(body.get("color_order"), str):
co = body["color_order"].strip().lower()
if co in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = co
if isinstance(body.get("startup_mode"), str):
sm = body["startup_mode"].strip().lower()
if sm in ("default", "last", "off"):
dc["startup_mode"] = sm
if not dc:
return json.dumps(
{
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}
), 400, {"Content-Type": "application/json"}
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, 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": "driver-config sent"}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
"""
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
"""
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",
}
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",
}
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",
}
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",
}