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

@@ -1,54 +0,0 @@
# espnow-sender (ESP-NOW bridge)
ESP32 firmware that relays **binary** ESP-NOW packets to/from led-controller over WebSocket.
Layout matches **led-driver** so you deploy with **led-tool** from this directory:
```
espnow-sender/
src/ # uploaded to device root via --src
main.py
wifi_ap.py
util.py
espnow_wire.py
lib/ # uploaded to /lib via --lib
aioespnow.py
microdot/
```
## Deploy with led-tool
```bash
cd espnow-sender
python ../led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
```
| Flag | Effect |
|------|--------|
| `--src` | Upload `./src` → device `:/` (`main.py`, `util.py`, `espnow_wire.py`) |
| `--lib` | Upload `./lib` → device `/lib` (aioespnow, Microdot) |
| `-r` | Reset after upload |
| `-f` | Follow serial output |
From **led-controller** root:
```bash
python led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
```
(run with `cwd` = `espnow-sender`, or `cd espnow-sender` first)
Optional: `--force-upload` to ignore `file_hashes.json` on the device.
## Runtime
- **Wi-Fi access point** (default IP **192.168.4.1**): connect the Pi to the bridge SSID (`name` in `/settings.json`, e.g. `bridge-aabbccddeeff`)
- WebSocket server: `/ws` on port **80** — set Pi `bridge_ws_url` to `ws://192.168.4.1/ws` (or the printed IP)
- Optional `ap_password` in `/settings.json` (empty = open network)
- Default Wi-Fi channel: **6** (Pi sends `BRIDGE_CH` on connect; updates AP + ESP-NOW STA)
- Max **20** ESP-NOW peers (LRU eviction)
## Protocol
- [docs/espnow-architecture.md](../docs/espnow-architecture.md)
- [docs/espnow-binary-protocol.md](../docs/espnow-binary-protocol.md)

View File

@@ -1,28 +0,0 @@
# aioespnow module for MicroPython on ESP32 and ESP8266
# MIT license; Copyright (c) 2022 Glenn Moloney @glenn20
# Vendored from micropython-lib/micropython/aioespnow
import asyncio
import espnow
class AIOESPNow(espnow.ESPNow):
async def arecv(self):
yield asyncio.core._io_queue.queue_read(self)
return self.recv(0)
async def airecv(self):
yield asyncio.core._io_queue.queue_read(self)
return self.irecv(0)
async def asend(self, mac, msg=None, sync=None):
if msg is None:
msg, mac = mac, None
yield asyncio.core._io_queue.queue_write(self)
return self.send(mac, msg, sync)
def __aiter__(self):
return self
async def __anext__(self):
return await self.airecv()

View File

@@ -1,2 +0,0 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401

View File

@@ -1,8 +0,0 @@
try:
from functools import wraps
except ImportError: # pragma: no cover
# MicroPython does not currently implement functools.wraps
def wraps(wrapped):
def _(wrapper):
return wrapper
return _

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
try:
import jwt
HAS_JWT = True
except ImportError:
HAS_JWT = False
try:
import ubinascii
except ImportError:
import binascii as ubinascii
try:
import uhashlib as hashlib
except ImportError:
import hashlib
try:
import uhmac as hmac
except ImportError:
try:
import hmac
except ImportError:
hmac = None
import json
from microdot.microdot import invoke_handler
from microdot.helpers import wraps
class SessionDict(dict):
"""A session dictionary.
The session dictionary is a standard Python dictionary that has been
extended with convenience ``save()`` and ``delete()`` methods.
"""
def __init__(self, request, session_dict):
super().__init__(session_dict)
self.request = request
def save(self):
"""Update the session cookie."""
self.request.app._session.update(self.request, self)
def delete(self):
"""Delete the session cookie."""
self.request.app._session.delete(self.request)
class Session:
"""Session handling
:param app: The application instance.
:param secret_key: The secret key, as a string or bytes object.
:param cookie_options: A dictionary with cookie options to pass as
arguments to :meth:`Response.set_cookie()
<microdot.Response.set_cookie>`.
"""
secret_key = None
def __init__(self, app=None, secret_key=None, cookie_options=None):
self.secret_key = secret_key
self.cookie_options = cookie_options or {}
if app is not None:
self.initialize(app)
def initialize(self, app, secret_key=None, cookie_options=None):
if secret_key is not None:
self.secret_key = secret_key
if cookie_options is not None:
self.cookie_options = cookie_options
if 'path' not in self.cookie_options:
self.cookie_options['path'] = '/'
if 'http_only' not in self.cookie_options:
self.cookie_options['http_only'] = True
app._session = self
def get(self, request):
"""Retrieve the user session.
:param request: The client request.
The return value is a session dictionary with the data stored in the
user's session, or ``{}`` if the session data is not available or
invalid.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
if hasattr(request.g, '_session'):
return request.g._session
session = request.cookies.get('session')
if session is None:
request.g._session = SessionDict(request, {})
return request.g._session
request.g._session = SessionDict(request, self.decode(session))
return request.g._session
def update(self, request, session):
"""Update the user session.
:param request: The client request.
:param session: A dictionary with the update session data for the user.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.save` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session['foo'] = 'bar'
session.save()
return 'Hello, World!'
Calling this method adds a cookie with the updated session to the
request currently being processed.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
encoded_session = self.encode(session)
@request.after_request
def _update_session(request, response):
response.set_cookie('session', encoded_session,
**self.cookie_options)
return response
def delete(self, request):
"""Remove the user session.
:param request: The client request.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.delete` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session.delete()
return 'Hello, World!'
Calling this method adds a cookie removal header to the request
currently being processed.
"""
@request.after_request
def _delete_session(request, response):
response.delete_cookie('session', **self.cookie_options)
return response
def encode(self, payload, secret_key=None):
"""Encode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
return jwt.encode(payload, secret_key or self.secret_key,
algorithm='HS256')
else:
# Simple encoding for MicroPython: base64(json) + HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
payload_json = json.dumps(payload)
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
# Create HMAC signature
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
return f"{payload_b64}.{signature}"
def decode(self, session, secret_key=None):
"""Decode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
try:
payload = jwt.decode(session, secret_key or self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
return {}
return payload
else:
try:
# Simple decoding for MicroPython
if '.' not in session:
return {}
payload_b64, signature = session.rsplit('.', 1)
payload_json = ubinascii.a2b_base64(payload_b64).decode()
# Verify HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
if signature != expected_signature:
return {}
return json.loads(payload_json)
except Exception:
return {}
def with_session(f):
"""Decorator that passes the user session to the route handler.
The session dictionary is passed to the decorated function as an argument
after the request object. Example::
@app.route('/')
@with_session
def index(request, session):
return 'Hello, World!'
Note that the decorator does not save the session. To update the session,
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
"""
@wraps(f)
async def wrapper(request, *args, **kwargs):
return await invoke_handler(
f, request, request.app._session.get(request), *args, **kwargs)
return wrapper

View File

@@ -1,70 +0,0 @@
from utemplate import recompile
_loader = None
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
"""
@classmethod
def initialize(cls, template_dir='templates',
loader_class=recompile.Loader):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load
templates from a *templates* subdirectory.
:param loader_class: the ``utemplate.Loader`` class to use when loading
templates. This argument is optional. The default
is the ``recompile.Loader`` class, which
automatically recompiles templates when they
change.
"""
global _loader
_loader = loader_class(None, template_dir)
def __init__(self, template):
if _loader is None: # pragma: no cover
self.initialize()
#: The name of the template
self.name = template
self.template = _loader.load(template)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the
given arguments."""
return self.template(*args, **kwargs)
def render(self, *args, **kwargs):
"""Render the template with the given arguments and return it as a
string."""
return ''.join(self.generate(*args, **kwargs))
def generate_async(self, *args, **kwargs):
"""Return an asynchronous generator that renders the template in
chunks, using the given arguments."""
class sync_to_async_iter():
def __init__(self, iter):
self.iter = iter
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.iter)
except StopIteration:
raise StopAsyncIteration
return sync_to_async_iter(self.generate(*args, **kwargs))
async def render_async(self, *args, **kwargs):
"""Render the template with the given arguments asynchronously and
return it as a string."""
response = ''
async for chunk in self.generate_async(*args, **kwargs):
response += chunk
return response

View File

@@ -1,231 +0,0 @@
import binascii
import hashlib
from microdot import Request, Response
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
from microdot.helpers import wraps
class WebSocketError(Exception):
"""Exception raised when an error occurs in a WebSocket connection."""
pass
class WebSocket:
"""A WebSocket connection object.
An instance of this class is sent to handler functions to manage the
WebSocket connection.
"""
CONT = 0
TEXT = 1
BINARY = 2
CLOSE = 8
PING = 9
PONG = 10
#: Specify the maximum message size that can be received when calling the
#: ``receive()`` method. Messages with payloads that are larger than this
#: size will be rejected and the connection closed. Set to 0 to disable
#: the size check (be aware of potential security issues if you do this),
#: or to -1 to use the value set in
#: ``Request.max_body_length``. The default is -1.
#:
#: Example::
#:
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
max_message_length = -1
def __init__(self, request):
self.request = request
self.closed = False
async def handshake(self):
response = self._handshake_response()
await self.request.sock[1].awrite(
b'HTTP/1.1 101 Switching Protocols\r\n')
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
await self.request.sock[1].awrite(
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
async def receive(self):
"""Receive a message from the client."""
while True:
opcode, payload = await self._read_frame()
send_opcode, data = self._process_websocket_frame(opcode, payload)
if send_opcode: # pragma: no cover
await self.send(data, send_opcode)
elif data: # pragma: no branch
return data
async def send(self, data, opcode=None):
"""Send a message to the client.
:param data: the data to send, given as a string or bytes.
:param opcode: a custom frame opcode to use. If not given, the opcode
is ``TEXT`` or ``BINARY`` depending on the type of the
data.
"""
frame = self._encode_websocket_frame(
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
data)
await self.request.sock[1].awrite(frame)
async def close(self):
"""Close the websocket connection."""
if not self.closed: # pragma: no cover
self.closed = True
await self.send(b'', self.CLOSE)
def _handshake_response(self):
connection = False
upgrade = False
websocket_key = None
for header, value in self.request.headers.items():
h = header.lower()
if h == 'connection':
connection = True
if 'upgrade' not in value.lower():
return self.request.app.abort(400)
elif h == 'upgrade':
upgrade = True
if not value.lower() == 'websocket':
return self.request.app.abort(400)
elif h == 'sec-websocket-key':
websocket_key = value
if not connection or not upgrade or not websocket_key:
return self.request.app.abort(400)
d = hashlib.sha1(websocket_key.encode())
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
return binascii.b2a_base64(d.digest())[:-1]
@classmethod
def _parse_frame_header(cls, header):
fin = header[0] & 0x80
opcode = header[0] & 0x0f
if fin == 0 or opcode == cls.CONT: # pragma: no cover
raise WebSocketError('Continuation frames not supported')
has_mask = header[1] & 0x80
length = header[1] & 0x7f
if length == 126:
length = -2
elif length == 127:
length = -8
return fin, opcode, has_mask, length
def _process_websocket_frame(self, opcode, payload):
if opcode == self.TEXT:
payload = payload.decode()
elif opcode == self.BINARY:
pass
elif opcode == self.CLOSE:
raise WebSocketError('Websocket connection closed')
elif opcode == self.PING:
return self.PONG, payload
elif opcode == self.PONG: # pragma: no branch
return None, None
return None, payload
@classmethod
def _encode_websocket_frame(cls, opcode, payload):
frame = bytearray()
frame.append(0x80 | opcode)
if opcode == cls.TEXT:
payload = payload.encode()
if len(payload) < 126:
frame.append(len(payload))
elif len(payload) < (1 << 16):
frame.append(126)
frame.extend(len(payload).to_bytes(2, 'big'))
else:
frame.append(127)
frame.extend(len(payload).to_bytes(8, 'big'))
frame.extend(payload)
return frame
async def _read_frame(self):
header = await self.request.sock[0].read(2)
if len(header) != 2: # pragma: no cover
raise WebSocketError('Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header)
if length == -2:
length = await self.request.sock[0].read(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = int.from_bytes(length, 'big')
max_allowed_length = Request.max_body_length \
if self.max_message_length == -1 else self.max_message_length
if length > max_allowed_length:
raise WebSocketError('Message too large')
if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length)
if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload
async def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
async def echo(request):
if not authenticate_user(request):
abort(401)
ws = await websocket_upgrade(request)
while True:
message = await ws.receive()
await ws.send(message)
"""
ws = WebSocket(request)
await ws.handshake()
@request.after_request
async def after_request(request, response):
return Response.already_handled
return ws
def websocket_wrapper(f, upgrade_function):
@wraps(f)
async def wrapper(request, *args, **kwargs):
ws = await upgrade_function(request)
try:
await f(request, ws, *args, **kwargs)
except OSError as exc:
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
raise
except WebSocketError:
pass
except Exception as exc:
print_exception(exc)
finally: # pragma: no cover
try:
await ws.close()
except Exception:
pass
return Response.already_handled
return wrapper
def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
"""
return websocket_wrapper(f, websocket_upgrade)

View File

@@ -1,22 +0,0 @@
{
"v": "1",
"dv": {
"ff:ff:ff:ff:ff:ff": {
"p": {
"preset_id": {
"p": "on",
"c": ["#FF0000"],
"d": 100,
"b": 255,
"a": true
}
},
"s": ["preset_id", 0],
"sv": true,
"df": "preset_id",
"b": 255,
"g": ["5", "18"],
"sg": true
}
}
}

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])