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:
28
espnow-sender/src/espnow_wire.py
Normal file
28
espnow-sender/src/espnow_wire.py
Normal 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
76
espnow-sender/src/main.py
Normal 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())
|
||||
73
espnow-sender/src/settings.py
Normal file
73
espnow-sender/src/settings.py
Normal 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
49
espnow-sender/src/util.py
Normal 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))
|
||||
66
espnow-sender/src/wifi_ap.py
Normal file
66
espnow-sender/src/wifi_ap.py
Normal 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])
|
||||
Reference in New Issue
Block a user