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 microdot import Microdot
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
|
from models.espnow import ESPNow
|
||||||
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
@@ -48,3 +50,79 @@ async def delete_preset(request, id):
|
|||||||
if presets.delete(id):
|
if presets.delete(id):
|
||||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
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
|
import asyncio
|
||||||
from settings import Settings
|
|
||||||
import gc
|
import gc
|
||||||
|
import json
|
||||||
import machine
|
import machine
|
||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
from microdot.websocket import with_websocket
|
from microdot.websocket import with_websocket
|
||||||
from microdot.session import Session
|
from microdot.session import Session
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
import aioespnow
|
import aioespnow
|
||||||
import network
|
import network
|
||||||
@@ -17,6 +18,7 @@ import controllers.palette as palette
|
|||||||
import controllers.scene as scene
|
import controllers.scene as scene
|
||||||
import controllers.pattern as pattern
|
import controllers.pattern as pattern
|
||||||
import controllers.settings as settings_controller
|
import controllers.settings as settings_controller
|
||||||
|
from models.espnow import ESPNow
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
@@ -25,10 +27,8 @@ async def main(port=80):
|
|||||||
|
|
||||||
network.WLAN(network.STA_IF).active(True)
|
network.WLAN(network.STA_IF).active(True)
|
||||||
|
|
||||||
|
# Initialize ESPNow singleton (config + peers)
|
||||||
e = aioespnow.AIOESPNow()
|
esp = ESPNow()
|
||||||
e.active(True)
|
|
||||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
|
||||||
|
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@@ -85,9 +85,17 @@ async def main(port=80):
|
|||||||
async def ws(request, ws):
|
async def ws(request, ws):
|
||||||
while True:
|
while True:
|
||||||
data = await ws.receive()
|
data = await ws.receive()
|
||||||
if data:
|
|
||||||
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
|
|
||||||
print(data)
|
print(data)
|
||||||
|
if 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:
|
else:
|
||||||
break
|
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