Add ESPNow preset send backend support

Implement ESPNow helper model, WebSocket forwarding, and /presets/send endpoint that chunks and broadcasts presets to devices.
This commit is contained in:
2026-01-28 04:43:45 +13:00
parent 928263fbd8
commit 8503315bef
3 changed files with 154 additions and 7 deletions

View File

@@ -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'}

View File

@@ -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

61
src/models/espnow.py Normal file
View File

@@ -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)