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:
@@ -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'}
|
||||
|
||||
|
||||
22
src/main.py
22
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
|
||||
|
||||
|
||||
61
src/models/espnow.py
Normal file
61
src/models/espnow.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user