feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
22
bridge-wifi/README.md
Normal file
22
bridge-wifi/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# bridge-wifi
|
||||
|
||||
ESP32 ESP-NOW bridge with **Wi‑Fi AP + WebSocket** (`/ws`). Same ESP-NOW downlink as bridge-serial.
|
||||
|
||||
```
|
||||
bridge-wifi/
|
||||
src/
|
||||
main.py
|
||||
settings.py
|
||||
wifi_ap.py
|
||||
espnow_wire.py # uplink frame helper only
|
||||
lib/microdot/ # WebSocket server
|
||||
```
|
||||
|
||||
Deploy:
|
||||
|
||||
```bash
|
||||
cd bridge-wifi
|
||||
python ../led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
|
||||
```
|
||||
|
||||
Pi: join bridge AP, `bridge_ws_url` → `ws://192.168.4.1/ws`.
|
||||
2
bridge-wifi/lib/microdot/__init__.py
Normal file
2
bridge-wifi/lib/microdot/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
|
||||
send_file # noqa: F401
|
||||
8
bridge-wifi/lib/microdot/helpers.py
Normal file
8
bridge-wifi/lib/microdot/helpers.py
Normal 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 _
|
||||
1450
bridge-wifi/lib/microdot/microdot.py
Normal file
1450
bridge-wifi/lib/microdot/microdot.py
Normal file
File diff suppressed because it is too large
Load Diff
225
bridge-wifi/lib/microdot/session.py
Normal file
225
bridge-wifi/lib/microdot/session.py
Normal 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
|
||||
70
bridge-wifi/lib/microdot/utemplate.py
Normal file
70
bridge-wifi/lib/microdot/utemplate.py
Normal 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
|
||||
231
bridge-wifi/lib/microdot/websocket.py
Normal file
231
bridge-wifi/lib/microdot/websocket.py
Normal 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)
|
||||
7
bridge-wifi/src/espnow_wire.py
Normal file
7
bridge-wifi/src/espnow_wire.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""WebSocket uplink framing (Pi ↔ bridge)."""
|
||||
|
||||
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
|
||||
|
||||
|
||||
def pack_ws_uplink(peer, espnow_packet):
|
||||
return bytes([0]) + peer + espnow_packet
|
||||
218
bridge-wifi/src/main.py
Normal file
218
bridge-wifi/src/main.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""ESP-NOW bridge: Pi WebSocket downlink, ESP-NOW to drivers."""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import time
|
||||
|
||||
import espnow
|
||||
import machine
|
||||
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
|
||||
from microdot import Microdot
|
||||
from microdot.websocket import WebSocketError, with_websocket
|
||||
from settings import Settings
|
||||
from wifi_ap import init_bridge_network
|
||||
|
||||
BROADCAST = BROADCAST_MAC
|
||||
WIRE = 0x4C
|
||||
MAX_ESPNOW = 250
|
||||
ESPNOW_EXIST = -12395
|
||||
ESPNOW_FULL = -12392
|
||||
|
||||
|
||||
def mac_str(mac):
|
||||
return ":".join("%02x" % b for b in mac)
|
||||
|
||||
|
||||
def dbg(msg):
|
||||
if DEBUG:
|
||||
print(msg)
|
||||
|
||||
|
||||
def add_peer_if_needed(esp, dest, ch):
|
||||
try:
|
||||
esp.add_peer(dest, channel=ch)
|
||||
dbg("peer add " + mac_str(dest))
|
||||
except TypeError:
|
||||
try:
|
||||
esp.add_peer(dest)
|
||||
dbg("peer add " + mac_str(dest))
|
||||
except OSError as e:
|
||||
if e.args[0] != ESPNOW_EXIST:
|
||||
raise
|
||||
dbg("peer exists " + mac_str(dest))
|
||||
except OSError as e:
|
||||
if e.args[0] != ESPNOW_EXIST:
|
||||
raise
|
||||
dbg("peer exists " + mac_str(dest))
|
||||
|
||||
|
||||
def del_peer_if_present(esp, dest):
|
||||
try:
|
||||
esp.del_peer(dest)
|
||||
dbg("peer del " + mac_str(dest))
|
||||
except Exception as e:
|
||||
dbg("peer del skip " + mac_str(dest) + " " + repr(e))
|
||||
|
||||
|
||||
def send_espnow(esp, dest, pkt):
|
||||
try:
|
||||
esp.send(dest, pkt, True)
|
||||
return True
|
||||
except OSError as e:
|
||||
label = "bcast" if dest == BROADCAST else mac_str(dest)
|
||||
print("send err", label, len(pkt), e)
|
||||
return False
|
||||
|
||||
|
||||
def send_unicast_temp_peer(esp, dest, ch, pkt):
|
||||
try:
|
||||
add_peer_if_needed(esp, dest, ch)
|
||||
except OSError as e:
|
||||
# If peer table is full but this peer already exists, delete+retry once.
|
||||
if e.args and e.args[0] == ESPNOW_FULL:
|
||||
dbg("peer full " + mac_str(dest) + " retry")
|
||||
del_peer_if_present(esp, dest)
|
||||
add_peer_if_needed(esp, dest, ch)
|
||||
else:
|
||||
raise
|
||||
ok = send_espnow(esp, dest, pkt)
|
||||
del_peer_if_present(esp, dest)
|
||||
return ok
|
||||
|
||||
|
||||
def downlink(esp, ch, raw):
|
||||
n = len(raw)
|
||||
if not raw:
|
||||
return
|
||||
if raw[0] == WIRE:
|
||||
if n < 2:
|
||||
dbg("dl skip wire short " + str(n))
|
||||
return
|
||||
dbg("dl wire bcast " + str(n))
|
||||
send_espnow(esp, BROADCAST, raw)
|
||||
return
|
||||
if n < 8 or raw[0] != ord("{"):
|
||||
dbg("dl skip json " + str(n))
|
||||
return
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
dbg("dl skip json")
|
||||
return
|
||||
devs = data.get("dv") or data.get("devices")
|
||||
if data.get("v") != "1" or not isinstance(devs, dict):
|
||||
dbg("dl skip envelope")
|
||||
return
|
||||
dbg("dl env " + str(len(devs)) + " dev")
|
||||
for mac_s, body in devs.items():
|
||||
if not isinstance(body, dict):
|
||||
dbg("dl skip body " + str(mac_s))
|
||||
continue
|
||||
try:
|
||||
h = str(mac_s).replace(":", "").replace("-", "").strip().lower()
|
||||
dest = BROADCAST if h == "ffffffffffff" else bytes.fromhex(h)
|
||||
msg = {"v": "1"}
|
||||
msg.update(body)
|
||||
pkt = json.dumps(msg, separators=(",", ":")).encode()
|
||||
if len(pkt) > MAX_ESPNOW:
|
||||
dbg("dl skip big " + str(len(pkt)))
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
dbg("dl skip mac " + str(mac_s))
|
||||
continue
|
||||
if dest == BROADCAST:
|
||||
dbg("dl bcast " + str(len(pkt)))
|
||||
send_espnow(esp, BROADCAST, pkt)
|
||||
else:
|
||||
dbg("dl uni " + mac_str(dest) + " " + str(len(pkt)))
|
||||
send_unicast_temp_peer(esp, dest, ch, pkt)
|
||||
time.sleep_ms(5)
|
||||
|
||||
|
||||
gc.collect()
|
||||
settings = Settings()
|
||||
DEBUG = bool(settings.get("debug", True))
|
||||
ch = max(1, min(11, int(settings.get("wifi_channel", 5))))
|
||||
init_bridge_network(settings)
|
||||
|
||||
esp = espnow.ESPNow()
|
||||
esp.active(True)
|
||||
add_peer_if_needed(esp, BROADCAST, ch)
|
||||
print(
|
||||
"bridge-wifi ch",
|
||||
ch,
|
||||
"debug",
|
||||
DEBUG,
|
||||
"heap",
|
||||
gc.mem_free(),
|
||||
"ws",
|
||||
int(settings.get("ws_port", 80)),
|
||||
)
|
||||
|
||||
app = Microdot()
|
||||
clients = set()
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws_handler(request, ws):
|
||||
clients.add(ws)
|
||||
print("ws client +", len(clients))
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
raw = await ws.receive()
|
||||
except WebSocketError:
|
||||
dbg("ws closed")
|
||||
break
|
||||
if not raw:
|
||||
dbg("ws empty")
|
||||
break
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("utf-8")
|
||||
dbg("ws rx " + str(len(raw)))
|
||||
try:
|
||||
downlink(esp, ch, raw)
|
||||
except OSError as e:
|
||||
print("dl err", e)
|
||||
finally:
|
||||
clients.discard(ws)
|
||||
print("ws client -", len(clients))
|
||||
|
||||
|
||||
async def espnow_rx_loop():
|
||||
while True:
|
||||
host, msg = esp.recv(0)
|
||||
if host:
|
||||
dbg("up " + mac_str(host) + " " + str(len(msg)))
|
||||
frame = pack_ws_uplink(host, msg)
|
||||
dead = []
|
||||
sent = 0
|
||||
for ws in list(clients):
|
||||
try:
|
||||
await ws.send(frame)
|
||||
sent += 1
|
||||
except Exception as e:
|
||||
dbg("ws up err " + repr(e))
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
clients.discard(ws)
|
||||
if not clients:
|
||||
dbg("up no ws clients")
|
||||
else:
|
||||
dbg("up ws " + str(sent) + "/" + str(len(clients)))
|
||||
else:
|
||||
await asyncio.sleep_ms(1)
|
||||
wdt.feed()
|
||||
|
||||
|
||||
async def main():
|
||||
asyncio.create_task(espnow_rx_loop())
|
||||
port = int(settings.get("ws_port", 80))
|
||||
print("ws listen", port)
|
||||
await app.start_server(host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
60
bridge-wifi/src/settings.py
Normal file
60
bridge-wifi/src/settings.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import time
|
||||
import ubinascii
|
||||
import network
|
||||
|
||||
WIFI_CHANNEL_DEFAULT = 5
|
||||
|
||||
|
||||
def _sta_mac_hex():
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
was_on = sta.active()
|
||||
if not was_on:
|
||||
sta.active(True)
|
||||
time.sleep_ms(50)
|
||||
try:
|
||||
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
|
||||
except Exception:
|
||||
mac = "000000000000"
|
||||
if not was_on:
|
||||
sta.active(False)
|
||||
return mac
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
SETTINGS_FILE = "/settings.json"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.load()
|
||||
|
||||
def set_defaults(self):
|
||||
self["name"] = "bridge-" + _sta_mac_hex()
|
||||
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
|
||||
self["ap_password"] = ""
|
||||
self["ap_ip"] = "192.168.4.1"
|
||||
self["ws_port"] = 80
|
||||
self["debug"] = True
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "w") as f:
|
||||
f.write(json.dumps(self))
|
||||
except Exception as e:
|
||||
print("save settings:", e)
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "r") as f:
|
||||
loaded = json.load(f)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("not object")
|
||||
except Exception:
|
||||
self.clear()
|
||||
self.set_defaults()
|
||||
self.save()
|
||||
return
|
||||
self.clear()
|
||||
self.set_defaults()
|
||||
for k, v in loaded.items():
|
||||
self[k] = v
|
||||
52
bridge-wifi/src/wifi_ap.py
Normal file
52
bridge-wifi/src/wifi_ap.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""AP + STA for ESP-NOW; Pi joins the AP for WebSocket."""
|
||||
|
||||
import time
|
||||
|
||||
import network
|
||||
|
||||
from settings import WIFI_CHANNEL_DEFAULT
|
||||
|
||||
|
||||
def _channel(settings):
|
||||
try:
|
||||
return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))))
|
||||
except (TypeError, ValueError):
|
||||
return WIFI_CHANNEL_DEFAULT
|
||||
|
||||
|
||||
def init_bridge_network(settings):
|
||||
ch = _channel(settings)
|
||||
essid = settings.get("name") or "bridge"
|
||||
password = settings.get("ap_password") or ""
|
||||
ap_ip = settings.get("ap_ip") or "192.168.4.1"
|
||||
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
sta.active(False)
|
||||
ap.active(False)
|
||||
time.sleep_ms(100)
|
||||
|
||||
ap.active(True)
|
||||
time.sleep_ms(50)
|
||||
if password:
|
||||
try:
|
||||
ap.config(essid=essid, password=password, channel=ch)
|
||||
except TypeError:
|
||||
ap.config(essid=essid, channel=ch)
|
||||
else:
|
||||
ap.config(essid=essid, channel=ch)
|
||||
try:
|
||||
ap.ifconfig((ap_ip, "255.255.255.0", ap_ip, "8.8.8.8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sta.active(True)
|
||||
sta.config(pm=network.WLAN.PM_NONE)
|
||||
try:
|
||||
sta.config(channel=ch)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
port = int(settings.get("ws_port", 80))
|
||||
print("bridge AP", essid, "ch", ch, "ip", ap.ifconfig()[0])
|
||||
print("bridge_ws_url: ws://%s:%s/ws" % (ap_ip, port))
|
||||
Reference in New Issue
Block a user