From 8503315bef8dd9f9d124ef7a324a35319fa24eef Mon Sep 17 00:00:00 2001 From: jimmy Date: Wed, 28 Jan 2026 04:43:45 +1300 Subject: [PATCH] Add ESPNow preset send backend support Implement ESPNow helper model, WebSocket forwarding, and /presets/send endpoint that chunks and broadcasts presets to devices. --- src/controllers/preset.py | 78 +++++++++++++++++++++++++++++++++++++++ src/main.py | 22 +++++++---- src/models/espnow.py | 61 ++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 src/models/espnow.py diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 081fb8f..d6e06d1 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -1,5 +1,7 @@ from microdot import Microdot from models.preset import Preset +from models.espnow import ESPNow +from util.espnow_message import build_message, build_preset_dict import json controller = Microdot() @@ -48,3 +50,79 @@ async def delete_preset(request, id): if presets.delete(id): return json.dumps({"message": "Preset deleted successfully"}), 200 return json.dumps({"error": "Preset not found"}), 404 + + +@controller.post('/send') +async def send_presets(request): + """ + Send one or more presets over ESPNow. + + Body JSON: + {"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]} + + The controller: + - looks up each preset in the Preset model + - converts them to API-compliant format + - splits into <= 240-byte ESPNow messages + - sends each message to all configured ESPNow peers. + """ + try: + data = request.json or {} + except Exception: + return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'} + + preset_ids = data.get('preset_ids') or data.get('ids') + if not isinstance(preset_ids, list) or not preset_ids: + return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'} + + # Build API-compliant preset map keyed by preset name + presets_by_name = {} + for pid in preset_ids: + preset_data = presets.read(str(pid)) + if not preset_data: + continue + name_key = preset_data.get('name') or str(pid) + presets_by_name[name_key] = build_preset_dict(preset_data) + + if not presets_by_name: + return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'} + + # Use shared ESPNow singleton + esp = ESPNow() + + async def send_chunk(chunk_presets): + msg = build_message(presets=chunk_presets) + await esp.send(msg) + + MAX_BYTES = 240 + entries = list(presets_by_name.items()) + total_presets = len(entries) + messages_sent = 0 + + batch = {} + last_msg = None + for name, preset_obj in entries: + test_batch = dict(batch) + test_batch[name] = preset_obj + test_msg = build_message(presets=test_batch) + size = len(test_msg) + + if size <= MAX_BYTES or not batch: + batch = test_batch + last_msg = test_msg + else: + await send_chunk(batch) + messages_sent += 1 + batch = {name: preset_obj} + last_msg = build_message(presets=batch) + + if batch: + await send_chunk(batch) + messages_sent += 1 + + return json.dumps({ + "message": "Presets sent via ESPNow", + "presets_sent": total_presets, + "messages_sent": messages_sent + }), 200, {'Content-Type': 'application/json'} + diff --git a/src/main.py b/src/main.py index aa7d395..685897d 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,11 @@ import asyncio -from settings import Settings import gc +import json import machine from microdot import Microdot, send_file from microdot.websocket import with_websocket from microdot.session import Session +from settings import Settings import aioespnow import network @@ -17,6 +18,7 @@ import controllers.palette as palette import controllers.scene as scene import controllers.pattern as pattern import controllers.settings as settings_controller +from models.espnow import ESPNow async def main(port=80): @@ -25,10 +27,8 @@ async def main(port=80): network.WLAN(network.STA_IF).active(True) - - e = aioespnow.AIOESPNow() - e.active(True) - e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb") + # Initialize ESPNow singleton (config + peers) + esp = ESPNow() app = Microdot() @@ -85,9 +85,17 @@ async def main(port=80): async def ws(request, ws): while True: data = await ws.receive() + print(data) if data: - await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data) - print(data) + # Debug: log incoming WebSocket data + try: + parsed = json.loads(data) + print("WS received JSON:", parsed) + except Exception: + print("WS received raw:", data) + + # Forward raw JSON payload over ESPNow to configured peers + await esp.send(data) else: break diff --git a/src/models/espnow.py b/src/models/espnow.py new file mode 100644 index 0000000..247e10c --- /dev/null +++ b/src/models/espnow.py @@ -0,0 +1,61 @@ +import aioespnow + + +class ESPNow: + """ + Singleton ESPNow helper: + - Manages a single AIOESPNow instance + - Adds a single broadcast-like peer + - Exposes async send(data) to send to that peer. + """ + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if getattr(self, "_initialized", False): + return + + # Initialize ESPNow once (no disk persistence) + self._esp = aioespnow.AIOESPNow() + self._esp.active(True) + + + try: + self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff") + except Exception: + # Ignore add_peer failures (e.g. duplicate) + pass + + self._initialized = True + + + async def send(self, data): + """ + Async send to the broadcast peer. + - data: bytes or str (JSON) + """ + if isinstance(data, str): + payload = data.encode() + else: + payload = data + + # Debug: show what we're sending and its size + try: + preview = payload.decode('utf-8') + except Exception: + preview = str(payload) + if len(preview) > 200: + preview = preview[:200] + "...(truncated)" + print("ESPNow.send len=", len(payload), "payload=", preview) + + try: + await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload) + except Exception as e: + # Log send failures but don't crash the app + print("ESPNow.send error:", e) +