feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -0,0 +1,91 @@
"""HTTP settings API for the ESP-NOW bridge (AP IP, password, channel)."""
import json
from settings import WIFI_CHANNEL_DEFAULT
_SETTINGS_KEYS = frozenset(
{"name", "ap_ip", "ap_password", "wifi_channel", "ws_port", "max_peers"}
)
def _parse_ipv4(value):
parts = str(value).strip().split(".")
if len(parts) != 4:
raise ValueError("ap_ip must be dotted IPv4")
out = []
for p in parts:
n = int(p)
if n < 0 or n > 255:
raise ValueError("ap_ip octet out of range")
out.append(n)
return ".".join(str(x) for x in out)
def public_settings(settings):
return {
"name": settings.get("name", ""),
"ap_ip": settings.get("ap_ip", "192.168.4.1"),
"ap_password_set": bool(str(settings.get("ap_password") or "").strip()),
"wifi_channel": settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT),
"ws_port": settings.get("ws_port", 80),
"max_peers": settings.get("max_peers", 20),
}
def apply_settings_update(settings, data):
if not isinstance(data, dict):
raise ValueError("body must be a JSON object")
reboot_required = False
if "name" in data:
name = str(data["name"] or "").strip()
if not name:
raise ValueError("name is required")
if len(name) > 32:
raise ValueError("name too long")
settings["name"] = name
reboot_required = True
if "ap_ip" in data:
settings["ap_ip"] = _parse_ipv4(data["ap_ip"])
reboot_required = True
if "ap_password" in data:
pw = str(data["ap_password"] or "")
if pw and len(pw) < 8:
raise ValueError("ap_password must be at least 8 characters or empty")
settings["ap_password"] = pw
reboot_required = True
if "wifi_channel" in data:
ch = int(data["wifi_channel"])
if ch < 1 or ch > 11:
raise ValueError("wifi_channel must be 111")
settings["wifi_channel"] = ch
reboot_required = True
if "ws_port" in data:
port = int(data["ws_port"])
if port < 1 or port > 65535:
raise ValueError("ws_port out of range")
settings["ws_port"] = port
if "max_peers" in data:
settings["max_peers"] = max(1, min(20, int(data["max_peers"])))
return reboot_required
def register_bridge_routes(app, settings):
@app.get("/settings")
async def get_bridge_settings(request):
return json.dumps(public_settings(settings)), 200, {"Content-Type": "application/json"}
@app.put("/settings")
async def put_bridge_settings(request):
try:
data = request.json
reboot_required = apply_settings_update(settings, data)
settings.save()
body = public_settings(settings)
body["message"] = "Settings saved"
body["reboot_required"] = reboot_required
return json.dumps(body), 200, {"Content-Type": "application/json"}
except ValueError as err:
return json.dumps({"error": str(err)}), 400, {"Content-Type": "application/json"}
except Exception as err:
return json.dumps({"error": str(err)}), 500, {"Content-Type": "application/json"}

View File

@@ -1,153 +0,0 @@
"""Route Pi v1 devices envelope to ESP-NOW unicast or broadcast."""
import json
import utime
from espnow_wire import BROADCAST_MAC
from util import parse_mac
from v1_wire import (
ENV_DEVICES,
K_PRESETS,
K_SELECT,
K_SET_GROUPS,
_WIRE_KEYS,
envelope_devices,
normalize_body,
)
MAX_ESPNOW_PAYLOAD = 250
_CHUNK_DELAY_MS = 50
def is_devices_envelope(raw):
if not raw:
return False
if isinstance(raw, str):
raw = raw.encode("utf-8")
if raw[0:1] != b"{":
return False
try:
data = json.loads(raw)
except (ValueError, TypeError):
return False
return (
isinstance(data, dict)
and data.get("v") == "1"
and envelope_devices(data) is not None
)
def _encode_v1(fields):
out = {"v": "1"}
short = normalize_body(fields)
for key in _WIRE_KEYS:
if key in short:
out[key] = short[key]
return json.dumps(out, separators=(",", ":")).encode("utf-8")
def _payload_len(fields):
return len(_encode_v1(fields))
def payloads_from_body(body):
"""One or more ESP-NOW payloads (each <= MAX_ESPNOW_PAYLOAD)."""
if not isinstance(body, dict):
raise ValueError("device body must be object")
short = normalize_body(body)
if _payload_len(short) <= MAX_ESPNOW_PAYLOAD:
return [_encode_v1(short)]
parts = []
meta = {}
for key in _WIRE_KEYS:
if key in short and key not in (K_PRESETS, K_SELECT):
meta[key] = short[key]
presets = short.get(K_PRESETS)
select = short.get(K_SELECT)
if presets and isinstance(presets, dict):
one = dict(meta)
one[K_PRESETS] = presets
if _payload_len(one) <= MAX_ESPNOW_PAYLOAD:
parts.append(_encode_v1(one))
else:
for pid, pdata in presets.items():
chunk = dict(meta)
chunk[K_PRESETS] = {pid: pdata}
if _payload_len(chunk) > MAX_ESPNOW_PAYLOAD:
raise ValueError(
"single preset too large (%d B)" % _payload_len(chunk)
)
parts.append(_encode_v1(chunk))
if select is not None:
sel = dict(meta)
sel.pop(K_SAVE, None)
sel[K_SELECT] = select
if _payload_len(sel) > MAX_ESPNOW_PAYLOAD:
raise ValueError("select too large (%d B)" % _payload_len(sel))
parts.append(_encode_v1(sel))
if not parts:
raise ValueError("driver payload too large (%d B)" % _payload_len(short))
return parts
async def ensure_peer(esp, mac_bytes):
try:
esp.add_peer(mac_bytes)
except Exception:
pass
async def send_unicast(esp, peer_table, mac_bytes, payload):
await ensure_peer(esp, mac_bytes)
peer_table.touch(mac_bytes)
await esp.asend(mac_bytes, payload)
async def _send_payloads(esp, peer_table, dest, payloads):
for i, payload in enumerate(payloads):
if peer_table.is_broadcast_mac(dest):
await ensure_peer(esp, BROADCAST_MAC)
await esp.asend(BROADCAST_MAC, payload)
else:
await send_unicast(esp, peer_table, dest, payload)
if i + 1 < len(payloads):
utime.sleep_ms(_CHUNK_DELAY_MS)
async def send_device_body(esp, peer_table, mac_str, body):
dest = parse_mac(mac_str)
payloads = payloads_from_body(body)
set_groups = bool(body.get("set_groups") or body.get("sg"))
if set_groups:
if peer_table.is_broadcast_mac(dest):
targets = peer_table.peers()
if not targets:
print("set_groups: no peers yet")
return
for peer in targets:
await _send_payloads(esp, peer_table, peer, payloads)
else:
await _send_payloads(esp, peer_table, dest, payloads)
return
await _send_payloads(esp, peer_table, dest, payloads)
async def route_envelope(esp, peer_table, raw):
if isinstance(raw, str):
raw = raw.encode("utf-8")
data = json.loads(raw)
devices = envelope_devices(data) or {}
for mac_str, body in devices.items():
try:
await send_device_body(esp, peer_table, mac_str, body)
except ValueError as err:
print("downlink skip", mac_str, err)
except Exception as err:
print("downlink err", mac_str, err)

View File

@@ -1,39 +0,0 @@
"""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 pack_ws_downlink(espnow_packet, peer_mac=None, broadcast=False):
flags = WS_FLAG_BROADCAST if broadcast else 0
if broadcast:
peer = BROADCAST_MAC
else:
if peer_mac is None or len(peer_mac) != 6:
raise ValueError("peer MAC required for unicast downlink")
peer = peer_mac
return bytes([flags]) + 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

View File

@@ -6,37 +6,28 @@ from microdot.websocket import WebSocketError, with_websocket
import aioespnow
import machine
import network
from settings import Settings
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
from peer_table import PeerTable, load_max_peers
from downlink_router import is_devices_envelope, route_envelope
from wifi_ap import init_bridge_network
from util import print_bridge_ip
from bridge_http import register_bridge_routes
from machine import UART, Pin
wdt = machine.WDT(timeout=10000)
wdt.feed()
machine.freq(160000000)
settings = Settings()
print(settings)
uart = UART(1, baudrate=921600, tx=Pin(2), rx=Pin(3))
app = Microdot()
register_bridge_routes(app, settings)
ch = settings.get("wifi_channel", 1)
try:
ch = max(1, min(11, int(ch)))
except (TypeError, ValueError):
ch = 1
ap_if = network.WLAN(network.AP_IF)
ap_if.active(True)
ap_if.config(
ssid=settings.get("name"),
password=settings.get("ap_password"),
channel=ch,
)
print(ap_if.ifconfig())
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
print(sta_if.config("channel"))
init_bridge_network(settings)
print_bridge_ip(settings.get("ws_port", 80))
esp = aioespnow.AIOESPNow()
esp.active(True)
@@ -56,7 +47,7 @@ def _note_uplink_peer(host, msg):
name = data.get("name")
except (ValueError, TypeError):
pass
peer_table.touch(host, name)
peer_table.touch(host, name, esp)
@app.route("/ws")
@@ -103,6 +94,26 @@ async def _espnow_receive_loop():
dead.append(client)
for client in dead:
clients.discard(client)
uart.write(msg)
async def _serial_receive_loop():
while True:
if uart.any():
raw = uart.read()
print(raw)
try:
if is_devices_envelope(raw):
await route_envelope(esp, peer_table, raw)
else:
await esp.asend(BROADCAST_MAC, raw)
print(raw)
print("ws tx", len(raw), "B")
except Exception as err:
print(err)
break
await asyncio.sleep(0)
async def _wdt_feed_loop():
@@ -114,6 +125,7 @@ async def _wdt_feed_loop():
async def main():
asyncio.create_task(_wdt_feed_loop())
asyncio.create_task(_espnow_receive_loop())
asyncio.create_task(_serial_receive_loop())
await app.start_server(host="0.0.0.0", port=80)

View File

@@ -7,24 +7,71 @@ try:
except ImportError:
Settings = None
# ESP32 counts the broadcast peer toward the ~20 peer limit.
_RESERVED_FOR_BROADCAST = 1
class PeerTable:
def __init__(self, max_peers=20):
self._max = max(1, int(max_peers))
limit = max(1, int(max_peers) - _RESERVED_FOR_BROADCAST)
self._max = limit
self._order = []
self._names = {}
def touch(self, mac_bytes, name=None):
def _evict_lru(self, esp):
if not self._order:
return
old = self._order.pop(0)
self._names.pop(old, None)
if esp is not None:
try:
esp.del_peer(old)
except OSError:
pass
def touch(self, mac_bytes, name=None, esp=None):
"""Note a peer from uplink (LRU). Pass ``esp`` so evictions free ESP-NOW slots."""
if not mac_bytes or len(mac_bytes) != 6:
return
if mac_bytes == BROADCAST_MAC:
return
if mac_bytes in self._order:
self._order.remove(mac_bytes)
elif len(self._order) >= self._max:
old = self._order.pop(0)
self._names.pop(old, None)
self._evict_lru(esp)
self._order.append(mac_bytes)
if name:
self._names[mac_bytes] = str(name)
if esp is not None:
try:
esp.add_peer(mac_bytes)
except OSError:
pass
def ensure_peer(self, esp, mac_bytes):
"""Register ``mac_bytes`` on ESP-NOW, evicting LRU peers when the table is full."""
if not mac_bytes or len(mac_bytes) != 6:
return False
if mac_bytes == BROADCAST_MAC:
try:
esp.add_peer(mac_bytes)
except OSError:
pass
return True
if mac_bytes in self._order:
self._order.remove(mac_bytes)
self._order.append(mac_bytes)
else:
while len(self._order) >= self._max:
self._evict_lru(esp)
self._order.append(mac_bytes)
# Uplink touch() only updates LRU; always add_peer before unicast send.
try:
esp.add_peer(mac_bytes)
except OSError as err:
print("add_peer failed", err)
return False
return True
def peers(self):
return list(self._order)

View File

@@ -1,73 +0,0 @@
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"] = 1
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

View File

@@ -42,8 +42,7 @@ def print_bridge_ip(ws_port=80):
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)
_label, ip = ips[0]
print("bridge IP (AP):", ip)
print("bridge_ws_url: ws://%s:%s/ws" % (ip, port))

View File

@@ -1,81 +0,0 @@
"""Short v1 wire keys (MicroPython)."""
K_PRESETS = "p"
K_SELECT = "s"
K_GROUPS = "g"
K_SET_GROUPS = "sg"
K_SAVE = "sv"
K_DEFAULT = "df"
K_DEVICE_CONFIG = "dc"
K_CLEAR_PRESETS = "cp"
K_MANIFEST = "mf"
ENV_DEVICES = "dv"
_LONG_TO_SHORT = {
"presets": K_PRESETS,
"select": K_SELECT,
"groups": K_GROUPS,
"set_groups": K_SET_GROUPS,
"save": K_SAVE,
"default": K_DEFAULT,
"device_config": K_DEVICE_CONFIG,
"clear_presets": K_CLEAR_PRESETS,
"manifest": K_MANIFEST,
}
def _normalize_select(val):
if isinstance(val, list):
return val
if isinstance(val, str) and val.strip():
return [val.strip()]
if isinstance(val, dict) and "preset" in val:
out = [val["preset"]]
if "step" in val:
out.append(val["step"])
return out
if isinstance(val, dict) and len(val) == 1:
one = next(iter(val.values()))
if isinstance(one, list):
return one
return val
_WIRE_KEYS = (
K_PRESETS,
K_SELECT,
K_SAVE,
K_DEFAULT,
"b",
K_GROUPS,
K_SET_GROUPS,
K_DEVICE_CONFIG,
K_CLEAR_PRESETS,
K_MANIFEST,
)
def normalize_body(body):
"""Long or short body → short keys for encoding."""
if not isinstance(body, dict):
return body
out = {}
for long_key, short_key in _LONG_TO_SHORT.items():
if long_key in body:
val = body[long_key]
if long_key == "select":
val = _normalize_select(val)
out[short_key] = val
elif short_key in body:
out[short_key] = body[short_key]
if "b" in body:
out["b"] = body["b"]
return out
def envelope_devices(data):
if not isinstance(data, dict):
return None
devs = data.get("devices")
if devs is None:
devs = data.get(ENV_DEVICES)
return devs if isinstance(devs, dict) else None

View File

@@ -1,66 +0,0 @@
"""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])