feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
91
espnow-sender/src/bridge_http.py
Normal file
91
espnow-sender/src/bridge_http.py
Normal 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 1–11")
|
||||
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"}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user