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

@@ -1,7 +1,54 @@
# espnow-sender
# espnow-sender (ESP-NOW bridge)
Minimal MicroPython project for receiving JSON over Microdot WebSocket.
ESP32 firmware that relays **binary** ESP-NOW packets to/from led-controller over WebSocket.
- WebSocket endpoint: `/ws`
- Entry point: `main.py`
- Message template: `msg.json`
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

@@ -0,0 +1,28 @@
# 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

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

View File

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,225 @@
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

@@ -0,0 +1,70 @@
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

@@ -0,0 +1,231 @@
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,120 +0,0 @@
import asyncio
import json
from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket
import espnow
import network
from util import format_mac, parse_mac
app = Microdot()
_esp = None
_known_peers = set()
_ws_clients = set()
def _init_espnow():
global _esp
sta = network.WLAN(network.STA_IF)
sta.active(True)
_esp = espnow.ESPNow()
_esp.active(True)
def _validate_envelope(obj):
if obj.get("v") != "1":
raise ValueError("message.v must be '1'")
devices = obj["devices"]
for address in devices.keys():
parse_mac(address)
return obj
def _send_espnow(address, payload):
if _esp is None:
raise ValueError("espnow is not initialized")
mac = parse_mac(address)
msg = json.dumps(payload, separators=(",", ":")).encode("utf-8")
if mac not in _known_peers:
_esp.add_peer(mac)
_known_peers.add(mac)
_esp.send(mac, msg)
return mac, len(msg)
async def _broadcast_ws(obj):
text = json.dumps(obj)
dead = []
for client in list(_ws_clients):
try:
await client.send(text)
except Exception:
dead.append(client)
for client in dead:
_ws_clients.discard(client)
async def _espnow_receive_loop():
while True:
host, msg = _esp.recv(0)
if not host:
await asyncio.sleep(0.01)
continue
await _broadcast_ws(
{
"from": format_mac(host),
"payload": msg.decode("utf-8"),
}
)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
_ws_clients.add(ws)
while True:
try:
raw = await ws.receive()
except WebSocketError:
break
if not raw:
break
try:
parsed = json.loads(raw)
env = _validate_envelope(parsed)
sent = []
for address, payload in env["devices"].items():
mac, payload_size = _send_espnow(address, payload)
sent.append(
{
"address": format_mac(mac),
"bytes": payload_size,
}
)
except (ValueError, TypeError) as e:
await ws.send(json.dumps({"ok": False, "error": str(e)}))
continue
await ws.send(
json.dumps(
{
"ok": True,
"sent": sent,
}
)
)
_ws_clients.discard(ws)
async def main(port=80):
_init_espnow()
asyncio.create_task(_espnow_receive_loop())
await app.start_server(host="0.0.0.0", port=port)
if __name__ == "__main__":
asyncio.run(main(port=80))

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

View File

@@ -1,12 +0,0 @@
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)