feat(espnow): Pi bridge client, binary wire, and espnow-sender firmware

Replace serial/Wi-Fi driver transport paths with WebSocket bridge client,
binary espnow_wire delivery, device announce registry, and restructured
espnow-sender (AP + broadcast passthrough). Includes docs and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 22:44:44 +12:00
parent f4ef85c182
commit 4fc3f46866
42 changed files with 4167 additions and 848 deletions

View File

@@ -0,0 +1,28 @@
"""ESP-NOW / WebSocket framing (MicroPython). See docs/espnow-binary-protocol.md."""
WIRE_MAGIC = 0x4C
MSG_BRIDGE_CH = 0x10
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
WS_FLAG_BROADCAST = 0x01
MAX_PEERS = 20
def parse_ws_downlink(frame):
"""Return (peer_bytes, espnow_packet, is_broadcast)."""
if not frame or len(frame) < 8:
raise ValueError("frame too short")
flags = frame[0]
peer = frame[1:7]
pkt = frame[7:]
broadcast = bool(flags & WS_FLAG_BROADCAST) or peer == BROADCAST_MAC
return peer, pkt, broadcast
def pack_ws_uplink(peer, espnow_packet):
return bytes([0]) + peer + espnow_packet
def parse_bridge_channel(pkt):
if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH:
return pkt[2]
return None

76
espnow-sender/src/main.py Normal file
View File

@@ -0,0 +1,76 @@
import asyncio
import time
from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket
import aioespnow
import machine
import network
from settings import Settings
wdt = machine.WDT(timeout=10000)
wdt.feed()
settings = Settings()
print(settings)
app = Microdot()
ap_if = network.WLAN(network.AP_IF)
ap_if.active(True)
ap_if.config(ssid=settings.get("name"), password=settings.get("ap_password"))
print(ap_if.ifconfig())
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
print(sta_if.config("channel"))
esp = aioespnow.AIOESPNow()
esp.active(True)
esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
clients = set()
@app.route("/ws")
@with_websocket
async def ws(request, ws):
clients.add(ws)
while True:
try:
raw = await ws.receive()
except WebSocketError as err:
print(err)
break
if not raw:
break
try:
await esp.asend(b"\xff\xff\xff\xff\xff\xff", raw)
print(raw)
except Exception as err:
print(err)
break
ws.close()
clients.discard(ws)
async def _espnow_receive_loop():
async for host, msg in esp.airecv():
print(host, msg)
for client in clients:
await client.send(msg)
async def _wdt_feed_loop():
while True:
await asyncio.sleep(1)
wdt.feed()
async def main():
asyncio.create_task(_wdt_feed_loop())
asyncio.create_task(_espnow_receive_loop())
await app.start_server(host="0.0.0.0", port=80)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,73 @@
import json
import time
import ubinascii
import network
def _sta_mac_hex():
"""Read STA MAC without leaving the radio up (wifi_ap owns bring-up)."""
sta = network.WLAN(network.STA_IF)
was_on = False
try:
was_on = sta.active()
except Exception:
pass
if not was_on:
try:
sta.active(True)
time.sleep_ms(50)
except Exception:
pass
try:
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
except Exception:
mac = "000000000000"
if not was_on:
try:
sta.active(False)
except Exception:
pass
return mac
class Settings(dict):
SETTINGS_FILE = "/settings.json"
def __init__(self):
super().__init__()
self.load()
def set_defaults(self):
mac = _sta_mac_hex()
self["name"] = "bridge-" + mac
self["wifi_channel"] = 6
self["ap_password"] = ""
self["ap_ip"] = "192.168.4.1"
self["ws_port"] = 80
self["max_peers"] = 20
def save(self):
try:
with open(self.SETTINGS_FILE, "w") as file:
file.write(json.dumps(self))
except Exception as e:
print("Error saving settings:", e)
def load(self):
try:
with open(self.SETTINGS_FILE, "r") as file:
loaded = json.load(file)
if not isinstance(loaded, dict):
raise ValueError("settings.json is not an object")
except Exception:
print("Error loading settings")
self.clear()
self.set_defaults()
self.save()
return
self.clear()
self.set_defaults()
for k, v in loaded.items():
self[k] = v

49
espnow-sender/src/util.py Normal file
View File

@@ -0,0 +1,49 @@
def parse_mac(value):
raw = value.strip().lower().replace(":", "").replace("-", "")
if len(raw) != 12:
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
try:
return bytes.fromhex(raw)
except ValueError:
raise ValueError("address contains non-hex characters")
def format_mac(mac_bytes):
return ":".join("{:02x}".format(b) for b in mac_bytes)
def print_bridge_ip(ws_port=80):
import network
try:
port = int(ws_port)
except (TypeError, ValueError):
port = 80
ips = []
try:
sta = network.WLAN(network.STA_IF)
if sta.active():
ip = sta.ifconfig()[0]
if ip and ip != "0.0.0.0":
ips.append(("STA", ip))
except Exception:
pass
try:
ap = network.WLAN(network.AP_IF)
if ap.active():
ip = ap.ifconfig()[0]
if ip and ip != "0.0.0.0":
ips.append(("AP", ip))
except Exception:
pass
if not ips:
print("bridge IP: (AP not up)")
return
# Prefer AP address — Pi joins the bridge access point.
ips.sort(key=lambda x: 0 if x[0] == "AP" else 1)
label, ip = ips[0]
print("bridge IP (%s):" % label, ip)
print("bridge_ws_url: ws://%s:%s/ws" % (ip, port))

View File

@@ -0,0 +1,66 @@
"""Bridge Wi-Fi: AP for Pi WebSocket client, STA for ESP-NOW (ESP32-C3: AP first)."""
import time
import network
def _wait_active(wlan, timeout_ms=1000):
for _ in range(timeout_ms // 20):
if wlan.active():
return True
time.sleep_ms(20)
return bool(wlan.active())
def _boot_channel(settings):
try:
return max(1, min(11, int(settings.get("wifi_channel", 6))))
except (TypeError, ValueError):
return 6
def init_bridge_network(settings):
"""Bring up AP (Pi) then STA (ESP-NOW). Channel set on AP at boot only."""
ch = _boot_channel(settings)
sta = network.WLAN(network.STA_IF)
ap = network.WLAN(network.AP_IF)
try:
sta.active(False)
ap.active(False)
except Exception:
pass
time.sleep_ms(100)
essid = settings.get("name") or "espnow-bridge"
password = settings.get("ap_password") or ""
ap.active(True)
if not _wait_active(ap):
raise RuntimeError("AP did not become active")
if password:
ap.config(essid=essid, password=password, channel=ch)
else:
ap.config(essid=essid, channel=ch)
ap_ip = settings.get("ap_ip") or "192.168.4.1"
try:
ap.ifconfig((ap_ip, "255.255.255.0", ap_ip, "8.8.8.8"))
except Exception as e:
print("ap ifconfig:", e)
sta.active(True)
if not _wait_active(sta):
raise RuntimeError("STA did not become active")
try:
sta.config(pm=network.WLAN.PM_NONE)
except Exception:
pass
try:
actual = ap.config("channel")
except Exception:
actual = ch
print("bridge AP:", essid, "channel=", actual, "ip=", ap.ifconfig()[0])