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

@@ -10,12 +10,8 @@ from models.group import Group
from models.transport import get_current_sender
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from util.driver_patterns import driver_patterns_dir
from util.binary_driver_messages import v1_dict_to_cmd_packet
from util.espnow_message import build_message
import asyncio
import json
@@ -81,17 +77,8 @@ _pi_settings = get_settings()
def _device_live_connected(dev_dict):
"""
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
"""
tr = (dev_dict.get("transport") or "espnow").strip().lower()
if tr != "wifi":
return None
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
if not ip:
return False
return tcp_client_connected(ip)
"""ESP-NOW has no live session flag on the Pi."""
return None
def _device_json_with_live_status(dev_dict):
@@ -155,14 +142,13 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim
return b" 2" in first_line
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
async def _identify_send_off_after_delay(sender, dev_id, name):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
pkt = v1_dict_to_cmd_packet(
{"v": "1", "select": {name: ["off"]}},
)
await sender.send(pkt, addr=dev_id)
except Exception:
pass
@@ -184,27 +170,20 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
if not name:
return 400, "Device must have a name to identify"
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return 400, "Device has no IP address"
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
pkt = v1_dict_to_cmd_packet(
{
"v": "1",
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
"select": {name: [_IDENTIFY_PRESET_KEY]},
}
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return 503, "Wi-Fi driver not connected"
else:
await sender.send(msg, addr=dev_id)
ok = await sender.send(pkt, addr=dev_id)
if not ok:
return 503, "Send failed"
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
_identify_send_off_after_delay(sender, dev_id, name)
)
except Exception as e:
return 503, str(e)
@@ -236,11 +215,6 @@ async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dic
if not name:
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
continue
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
if not dev.get("address"):
errors.append({"mac": dev_id, "error": "Device has no IP address"})
continue
merged_select[name] = [_IDENTIFY_PRESET_KEY]
valid_macs.append(dev_id)
@@ -259,10 +233,8 @@ async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dic
for dev_id in valid_macs:
dev = devices.read(dev_id) or {}
name = str(dev.get("name") or "").strip()
transport = (dev.get("transport") or "espnow").strip().lower()
wifi_ip = dev.get("address") if transport == "wifi" else None
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
_identify_send_off_after_delay(sender, dev_id, name)
)
return len(valid_macs), errors
@@ -476,30 +448,20 @@ async def push_device_output_brightness(request, id):
zone_brightness=zb,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
ok = await send_json_line_to_ip(ip, msg)
pkt = v1_dict_to_cmd_packet({"v": "1", "b": b_val, "save": True})
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
ok = await sender.send(pkt, addr=id)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",
}
else:
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
await sender.send(msg, addr=id)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
"Content-Type": "application/json",
@@ -509,7 +471,7 @@ async def push_device_output_brightness(request, id):
@controller.post("/<id>/driver-config")
async def push_driver_config(request, id):
"""
Push ``device_config`` to a WiFi LED driver over WebSocket.
Push ``device_config`` to an ESP-NOW LED driver.
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
"""
dev = devices.read(id)
@@ -517,13 +479,9 @@ async def push_driver_config(request, id):
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "driver-config is only for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
body = request.json or {}
@@ -551,12 +509,10 @@ async def push_driver_config(request, id):
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}
), 400, {"Content-Type": "application/json"}
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
ok = await send_json_line_to_ip(wifi_ip, msg)
pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True})
ok = await sender.send(pkt, addr=id)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",
}
return json.dumps({"message": "driver-config sent"}), 200, {
@@ -567,71 +523,13 @@ async def push_driver_config(request, id):
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
"""
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
Pattern OTA over HTTP is not available for ESP-NOW drivers.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
base_dir = driver_patterns_dir()
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {
"Content-Type": "application/json",
}
files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
if not files:
return json.dumps({"error": "No pattern files found"}), 404, {
"Content-Type": "application/json",
}
sent = []
failed = []
total = len(files)
for idx, filename in enumerate(files):
path = os.path.join(base_dir, filename)
try:
with open(path, "r") as f:
code = f.read()
except OSError:
failed.append(filename)
continue
reload_patterns = idx == (total - 1)
ok = _http_post_pattern_source(
wifi_ip,
filename,
code,
reload_patterns=reload_patterns,
timeout_s=10.0,
)
if ok:
sent.append(filename)
else:
failed.append(filename)
if not sent:
return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
"Content-Type": "application/json",
}
return json.dumps({
"message": "Pattern files uploaded",
"sent_count": len(sent),
"sent": sent,
"failed": failed,
}), 200, {
"Content-Type": "application/json",
}
return json.dumps(
{"error": "Pattern OTA push is not supported for ESP-NOW devices"}
), 400, {"Content-Type": "application/json"}

View File

@@ -4,7 +4,8 @@ import asyncio
from models.group import Group
from models.device import Device
from models.transport import get_current_sender
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
from util.binary_driver_messages import v1_dict_to_cmd_packet
from util.espnow_registry import push_groups_for_group_devices
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
import json
@@ -101,6 +102,9 @@ async def create_group(request, session):
cur = get_current_profile_id(session)
if cur:
groups.update(group_id, {"profile_id": str(cur)})
g = groups.read(group_id)
if g:
await push_groups_for_group_devices(g)
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@@ -119,6 +123,7 @@ async def update_group(request, session, id):
if groups.update(id, data):
g = groups.read(id)
if g:
await push_groups_for_group_devices(g)
return json.dumps(g), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Group not found"}), 404
except Exception as e:
@@ -135,7 +140,9 @@ async def delete_group(request, session, id):
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
macs = list(g.get("devices") or []) if isinstance(g, dict) else []
if groups.delete(id):
await push_groups_for_group_devices({"devices": macs})
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404
@@ -184,7 +191,7 @@ def _read_group_for_session(session, id):
@with_session
async def push_group_driver_config(request, session, id):
"""
Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket).
Push group driver defaults to every ESP-NOW device listed in the group.
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
"""
gdoc = _read_group_for_session(session, id)
@@ -211,11 +218,10 @@ async def push_group_driver_config(request, session, id):
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
tasks = []
meta_macs = []
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503
pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True})
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
@@ -224,23 +230,13 @@ async def push_group_driver_config(request, session, id):
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
if (dev.get("transport") or "").lower() != "wifi":
continue
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
tasks.append(send_json_line_to_ip(ip, msg))
meta_macs.append(m)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for m, r in zip(meta_macs, results):
if r is True:
try:
if await sender.send(pkt, addr=m):
sent += 1
elif isinstance(r, Exception):
errors.append({"mac": m, "error": str(r)})
else:
errors.append({"mac": m, "error": "driver not connected"})
errors.append({"mac": m, "error": "send failed"})
except Exception as e:
errors.append({"mac": m, "error": str(e)})
return json.dumps(
{"message": "driver-config sent", "sent": sent, "errors": errors}
@@ -275,19 +271,14 @@ async def push_group_output_brightness(request, session, id):
m,
zone_brightness=None,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
return m, False, "no IP"
ok = await send_json_line_to_ip(ip, msg)
return m, bool(ok), None if ok else "driver not connected"
pkt = v1_dict_to_cmd_packet(
{"v": "1", "b": b_val, "save": True},
)
if not sender:
return m, False, "transport not configured"
try:
await sender.send(msg, addr=m)
return m, True, None
ok = await sender.send(pkt, addr=m)
return m, bool(ok), None if ok else "send failed"
except Exception as e:
return m, False, str(e)

View File

@@ -7,6 +7,7 @@ from models.device import Device, normalize_mac
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from util.espnow_message import build_message, build_preset_dict
from util.binary_driver_messages import build_preset_cmd_chunks
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
@@ -225,39 +226,13 @@ async def send_presets(request, session):
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
MAX_BYTES = 240
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
batch = {}
chunk_messages = []
for name, preset_obj in entries:
test_batch = dict(batch)
test_batch[name] = preset_obj
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
size = len(test_msg)
if size <= MAX_BYTES or not batch:
batch = test_batch
else:
chunk_messages.append(
build_message(
presets=dict(batch),
save=False,
default=None,
)
)
batch = {name: preset_obj}
if batch:
chunk_messages.append(
build_message(
presets=dict(batch),
save=save_flag,
default=default_id,
)
)
total_presets = len(presets_by_name)
chunk_messages = build_preset_cmd_chunks(
presets_by_name,
save=save_flag,
default=str(default_id) if default_id is not None else None,
)
target_list = None
raw_targets = data.get("targets")

View File

@@ -3,7 +3,6 @@ import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import get_settings
controller = Microdot()
@@ -108,13 +107,6 @@ async def update_settings(request):
else:
settings[key] = value
settings.save()
if global_brightness_changed:
try:
asyncio.get_running_loop().create_task(
wifi_ws_clients.broadcast_global_brightness_to_tcp_drivers()
)
except RuntimeError:
pass
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400

View File

@@ -4,9 +4,6 @@ import json
import os
import secrets
import signal
import socket
import threading
import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
@@ -24,171 +21,36 @@ import controllers.settings as settings_controller
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import wifi_ws_clients as tcp_client_registry
from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to,
broadcast_device_tcp_status,
register_device_status_ws,
unregister_device_status_ws,
)
from models.device import Device
from models.bridge_ws_client import init_bridge_client
from util.espnow_registry import handle_espnow_announce
from util.binary_driver_messages import v1_dict_to_cmd_packet
from util.audio_detector import AudioBeatDetector
_tcp_device_lock = threading.Lock()
DISCOVERY_UDP_PORT = 8766
def _live_reload_enabled() -> bool:
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
return v not in ("", "0", "false", "no")
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
with _tcp_device_lock:
try:
d = Device()
did, persisted = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type
)
if did and persisted:
print(
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
except Exception as e:
print(f"UDP device registry failed: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
while True:
try:
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
except asyncio.CancelledError:
raise
except OSError as e:
if udp_holder and udp_holder.get("closing"):
break
print(f"[UDP] recv failed: {e!r}")
continue
except Exception as e:
print(f"[UDP] recv failed: {e!r}")
continue
peer_ip = addr[0] if addr else ""
line = data.split(b"\n", 1)[0].strip()
if line:
try:
parsed = json.loads(line.decode("utf-8"))
if isinstance(parsed, dict):
dns = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
"sta_mac"
)
device_type = parsed.get("type") or parsed.get("device_type")
if dns and normalize_mac(mac):
_register_udp_device_sync(dns, peer_ip, mac, device_type)
if str(parsed.get("v") or "") == "1":
tcp_client_registry.ensure_driver_connection(peer_ip)
except (UnicodeError, ValueError, TypeError):
pass
try:
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
except Exception as e:
print(f"[UDP] echo send failed: {e!r}")
def _prime_wifi_outbound_driver_connections() -> None:
"""On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
n = 0
try:
dev = Device()
for mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
tcp_client_registry.ensure_driver_connection(ip)
n += 1
except Exception as e:
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
return
if n:
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
def _ipv4_address(addr: str) -> str | None:
"""Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
s = (addr or "").strip()
if not s:
return None
parts = s.split(".")
if len(parts) != 4:
return None
try:
nums = [int(p) for p in parts]
except ValueError:
return None
if not all(0 <= n <= 255 for n in nums):
return None
return s
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except (AttributeError, OSError):
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except (AttributeError, OSError):
pass
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
if udp_holder is not None:
udp_holder["sock"] = sock
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
try:
await _handle_udp_discovery(sock, udp_holder)
finally:
if udp_holder is not None:
udp_holder.pop("sock", None)
try:
sock.close()
except Exception:
pass
async def _send_bridge_wifi_channel(settings, sender):
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
try:
ch = int(settings.get("wifi_channel", 6))
except (TypeError, ValueError):
ch = 6
ch = max(1, min(11, ch))
payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
try:
await sender.send(payload, addr="ffffffffffff")
print(f"[startup] bridge Wi-Fi channel -> {ch}")
except Exception as e:
print(f"[startup] bridge channel message failed: {e}")
async def main(port=80):
settings = get_settings()
print(settings)
print("Starting")
# Initialize transport (serial to ESP32 bridge)
sender = get_sender(settings)
set_sender(sender)
bridge_url = str(settings.get("bridge_ws_url") or "").strip()
if bridge_url:
try:
ch = int(settings.get("wifi_channel", 6))
except (TypeError, ValueError):
ch = 6
bridge = init_bridge_client(bridge_url, wifi_channel=ch)
bridge.set_uplink_handler(handle_espnow_announce)
bridge.start()
app = Microdot()
audio_detector = AudioBeatDetector()
try:
@@ -243,9 +105,6 @@ async def main(port=80):
app.mount(device_controller.controller, '/devices')
app.mount(led_tool_controller.controller, '/led-tool')
tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
live_reload = _live_reload_enabled()
dev_build_id = secrets.token_hex(12) if live_reload else None
if live_reload:
@@ -408,56 +267,35 @@ async def main(port=80):
@app.route('/ws')
@with_websocket
async def ws(request, ws):
await register_device_status_ws(ws)
await broadcast_device_tcp_snapshot_to(ws)
try:
while True:
data = await ws.receive()
print(data)
if data:
try:
parsed = json.loads(data)
print("WS received JSON:", parsed)
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await sender.send(payload, addr=addr)
except json.JSONDecodeError:
# Not JSON: send raw with default address
try:
await sender.send(data)
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
else:
if not data:
break
finally:
await unregister_device_status_ws(ws)
try:
if isinstance(data, (bytes, bytearray)):
await sender.send(bytes(data))
continue
parsed = json.loads(data)
addr = parsed.pop("to", None)
pkt = v1_dict_to_cmd_packet(parsed)
await sender.send(pkt, addr=addr)
except json.JSONDecodeError:
pass
except Exception:
try:
await ws.send(json.dumps({"error": "Send failed"}))
except Exception:
pass
except Exception:
pass
# Touch Device singleton early so db/device.json exists before first UDP hello.
Device()
await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
udp_holder = {"closing": False, "shutting_down": False}
loop = asyncio.get_running_loop()
server_tasks: list[asyncio.Task] = []
def _graceful_shutdown(*_args):
if udp_holder.get("shutting_down"):
raise SystemExit(0)
udp_holder["shutting_down"] = True
print("[server] shutting down...")
udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
@@ -472,13 +310,6 @@ async def main(port=80):
t.cancel()
except Exception:
pass
u = udp_holder.get("sock")
if u is not None:
try:
u.close()
except OSError:
pass
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
try:
app.shutdown()
@@ -497,15 +328,11 @@ async def main(port=80):
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
server_tasks[:] = [
asyncio.create_task(
app.start_server(host="0.0.0.0", port=port), name="http"
),
asyncio.create_task(
_run_udp_discovery_server(udp_holder), name="udp"
),
]
await asyncio.gather(*server_tasks)
except asyncio.CancelledError:
@@ -534,7 +361,6 @@ async def main(port=80):
app.server = None
except Exception:
pass
udp_holder["closing"] = True
for t in list(server_tasks):
if not t.done():
t.cancel()

View File

@@ -0,0 +1,142 @@
"""Persistent WebSocket client to the ESP-NOW bridge (binary frames)."""
from __future__ import annotations
import asyncio
from typing import Awaitable, Callable, Optional
import websockets
from websockets.exceptions import ConnectionClosed
from util.espnow_wire import (
MSG_ANNOUNCE,
WIRE_MAGIC,
pack_bridge_channel,
pack_ws_downlink,
parse_ws_frame,
wire_msg_type,
)
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
class BridgeWsClient:
def __init__(self, url: str, *, wifi_channel: int = 6):
self._url = url.strip()
self._wifi_channel = wifi_channel
self._ws: Optional[websockets.WebSocketClientProtocol] = None
self._send_lock = asyncio.Lock()
self._uplink_handler: Optional[UplinkHandler] = None
self._task: Optional[asyncio.Task] = None
self._connected = asyncio.Event()
self._ack_waiter: Optional[asyncio.Future] = None
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
self._uplink_handler = handler
async def run_forever(self) -> None:
while True:
try:
await self._connect_once()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[bridge] connection error: {e!r}")
self._connected.clear()
self._ws = None
await asyncio.sleep(2.0)
async def _reader_loop(self) -> None:
ws = self._ws
if ws is None:
return
try:
async for message in ws:
if isinstance(message, str):
continue
if len(message) == 1:
fut = self._ack_waiter
if fut is not None and not fut.done():
fut.set_result(message[0] == 0x01)
continue
try:
peer, pkt, _bcast = parse_ws_frame(message)
except ValueError:
continue
if wire_msg_type(pkt) == MSG_ANNOUNCE and self._uplink_handler:
await self._uplink_handler(peer, pkt)
except ConnectionClosed:
pass
async def _connect_once(self) -> None:
print(f"[bridge] connecting to {self._url}")
async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws:
self._ws = ws
ch_pkt = pack_bridge_channel(self._wifi_channel)
await ws.send(pack_ws_downlink(ch_pkt, broadcast=True))
self._connected.set()
print("[bridge] connected")
reader = asyncio.create_task(self._reader_loop())
try:
while True:
await asyncio.sleep(3600)
finally:
reader.cancel()
try:
await reader
except asyncio.CancelledError:
pass
async def wait_connected(self, timeout: float = 30.0) -> bool:
try:
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
async def send_frame(self, frame: bytes) -> bool:
await self._connected.wait()
ws = self._ws
if ws is None:
return False
async with self._send_lock:
loop = asyncio.get_running_loop()
self._ack_waiter = loop.create_future()
try:
await ws.send(frame)
return bool(await asyncio.wait_for(self._ack_waiter, timeout=5.0))
except (ConnectionClosed, asyncio.TimeoutError, OSError) as e:
print(f"[bridge] send failed: {e!r}")
return False
finally:
self._ack_waiter = None
async def send_espnow(
self,
packet: bytes,
*,
peer_mac: Optional[str] = None,
broadcast: bool = False,
) -> bool:
if not packet or packet[0] != WIRE_MAGIC:
raise ValueError("packet must be espnow wire format")
frame = pack_ws_downlink(packet, peer_mac=peer_mac, broadcast=broadcast)
return await self.send_frame(frame)
def start(self) -> asyncio.Task:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self.run_forever())
return self._task
_client: Optional[BridgeWsClient] = None
def get_bridge_client() -> Optional[BridgeWsClient]:
return _client
def init_bridge_client(url: str, *, wifi_channel: int = 6) -> BridgeWsClient:
global _client
_client = BridgeWsClient(url, wifi_channel=wifi_channel)
return _client

View File

@@ -256,6 +256,68 @@ class Device(Model):
def list(self):
return list(self.keys())
def upsert_espnow_announced(
self,
mac,
device_name,
*,
device_type="led",
num_leds=None,
color_order=None,
startup_mode=None,
brightness=None,
):
"""
Register or update an ESP-NOW device from a binary ANNOUNCE.
Returns ``(mac_hex | None, persisted)``.
"""
mac_hex = normalize_mac(mac)
if not mac_hex:
return None, False
name = (device_name or "").strip()
if not name:
return None, False
resolved_type = validate_device_type(device_type)
meta = {}
if num_leds is not None:
meta["num_leds"] = int(num_leds)
if color_order is not None:
meta["color_order"] = str(color_order)
if startup_mode is not None:
meta["startup_mode"] = str(startup_mode)
if brightness is not None:
meta["brightness"] = int(brightness)
if mac_hex in self:
prev = self[mac_hex]
merged = dict(prev)
merged["name"] = name
merged["type"] = resolved_type
merged["transport"] = "espnow"
merged["address"] = mac_hex
merged["id"] = mac_hex
merged.update({k: v for k, v in meta.items() if v is not None})
if merged == prev:
return mac_hex, False
self[mac_hex] = merged
self.save()
return mac_hex, True
row = {
"id": mac_hex,
"name": name,
"type": resolved_type,
"transport": "espnow",
"address": mac_hex,
"default_pattern": None,
"zones": [],
}
row.update({k: v for k, v in meta.items() if v is not None})
self[mac_hex] = row
self.save()
return mac_hex, True
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
"""
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,

View File

@@ -1,59 +1,53 @@
"""Transport to LED drivers via ESP-NOW bridge WebSocket."""
import asyncio
import json
from typing import Optional, Union
from models.bridge_ws_client import get_bridge_client
from util.espnow_wire import WIRE_MAGIC, pack_ws_downlink
BROADCAST_MAC_HEX = "ffffffffffff"
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
def _encode_payload(data):
if isinstance(data, str):
return data.encode()
if isinstance(data, dict):
return json.dumps(data).encode()
return data
def _parse_mac(addr):
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
if addr is None or addr == b"":
return BROADCAST_MAC
def _parse_mac(addr) -> Optional[bytes]:
if addr is None or addr == "":
return None
if isinstance(addr, bytes) and len(addr) == 6:
return addr
if isinstance(addr, str) and len(addr) == 12:
return bytes.fromhex(addr)
return BROADCAST_MAC
async def _to_thread(func, *args):
to_thread = getattr(asyncio, "to_thread", None)
if to_thread:
return await to_thread(func, *args)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args)
if isinstance(addr, str):
s = addr.strip().lower().replace(":", "").replace("-", "")
if len(s) == 12:
return bytes.fromhex(s)
return None
class NullSender:
"""Used when no ESP-NOW UART bridge is configured or the port cannot be opened."""
"""No bridge configured."""
async def send(self, data, addr=None):
return True
class SerialSender:
def __init__(self, port, baudrate, default_addr=None):
import serial
class BridgeWsSender:
"""Send binary ESP-NOW packets via bridge WebSocket client."""
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
self._default_addr = _parse_mac(default_addr)
self._write_lock = asyncio.Lock()
async def send(self, data, addr=None):
mac = _parse_mac(addr) if addr is not None else self._default_addr
payload = _encode_payload(data)
async with self._write_lock:
await _to_thread(self._serial.write, mac + payload)
return True
async def send(self, data: Union[bytes, str, dict], addr=None) -> bool:
client = get_bridge_client()
if client is None:
return False
if isinstance(data, (bytes, bytearray)):
packet = bytes(data)
else:
return False
if not packet or packet[0] != WIRE_MAGIC:
return False
peer = _parse_mac(addr)
broadcast = peer is None or addr == BROADCAST_MAC_HEX
return await client.send_espnow(
packet,
peer_mac=peer,
broadcast=broadcast,
)
_current_sender = None
@@ -69,22 +63,11 @@ def get_current_sender():
def get_sender(settings):
# Serial ESP-NOW bridge is opt-in (serial_enabled true); default off for dev / Wi-Fi-only.
if not settings.get("serial_enabled"):
print("[startup] serial bridge disabled (set serial_enabled true in settings.json to enable)")
return NullSender()
port = settings.get("serial_port", "/dev/ttyS0")
raw_port = str(port).strip() if port is not None else ""
if not raw_port or raw_port.lower() in ("none", "off"):
print("[startup] serial bridge disabled (empty serial_port)")
return NullSender()
baudrate = settings.get("serial_baudrate", 912000)
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
try:
return SerialSender(raw_port, baudrate, default_addr=default_addr)
except Exception as e:
url = str(settings.get("bridge_ws_url") or "").strip()
if not url:
print(
f"[startup] serial open failed ({raw_port!r}): {e}; "
"continuing without ESP-NOW bridge (Wi-Fi drivers unchanged)"
"[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
)
return NullSender()
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
return BridgeWsSender()

View File

@@ -52,31 +52,9 @@ class Settings(dict):
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 6
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
if 'wifi_driver_ws_port' not in self:
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
# Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 0
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
if 'wifi_driver_connect_stagger_max_s' not in self:
self['wifi_driver_connect_stagger_max_s'] = 2.5
# TCP/WebSocket open timeout per attempt (seconds).
if 'wifi_driver_ws_open_timeout' not in self:
self['wifi_driver_ws_open_timeout'] = 45.0
# Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# Outbound WebSocket dial attempts per driver UDP hello (then wait for next hello).
if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
if 'serial_enabled' not in self:
self['serial_enabled'] = False
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
if 'bridge_ws_url' not in self:
self['bridge_ws_url'] = ''
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
@@ -91,9 +69,10 @@ class Settings(dict):
def save(self):
try:
j = json.dumps(self)
j = json.dumps(self, indent=2, sort_keys=True)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
file.write("\n")
if not getattr(self, "_quiet", False):
print("Settings saved successfully.")
except Exception as e:

View File

@@ -0,0 +1,62 @@
"""Build binary ESP-NOW CMD / GROUP_CMD packets from preset/select data."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from util.binary_envelope import pack_binary_envelope_v2
from util.espnow_wire import MAX_ESPNOW_PAYLOAD, pack_cmd, pack_group_cmd
def v1_dict_to_cmd_packet(body: Dict[str, Any]) -> bytes:
save = bool(body.get("save"))
kw: Dict[str, Any] = {}
if "presets" in body:
kw["presets"] = body["presets"]
if "select" in body:
kw["select"] = body["select"]
if "default" in body:
kw["default"] = body["default"]
kw["default_targets"] = body.get("targets")
if "b" in body:
kw["brightness_0_255"] = int(body["b"])
return pack_cmd(pack_binary_envelope_v2(**kw), save=save)
def build_preset_cmd_chunks(
presets_by_name: Dict[str, Any],
*,
save: bool = False,
default: Optional[str] = None,
max_payload: int = MAX_ESPNOW_PAYLOAD,
) -> List[bytes]:
"""Chunk presets into CMD packets each ≤ max_payload bytes."""
entries = list(presets_by_name.items())
chunks: List[bytes] = []
batch: Dict[str, Any] = {}
def _packet_for(presets_map: Dict[str, Any], *, final_save: bool, def_id: Optional[str]):
kw: Dict[str, Any] = {"presets": presets_map}
if def_id is not None:
kw["default"] = def_id
return pack_cmd(pack_binary_envelope_v2(**kw), save=final_save)
for name, preset_obj in entries:
trial = dict(batch)
trial[name] = preset_obj
try:
pkt = _packet_for(trial, final_save=False, def_id=None)
except ValueError:
pkt = b"\xff\xff"
if len(pkt) <= max_payload or not batch:
batch = trial
else:
chunks.append(_packet_for(batch, final_save=False, def_id=None))
batch = {name: preset_obj}
if batch:
chunks.append(
_packet_for(batch, final_save=save, def_id=str(default) if default else None),
)
return [c for c in chunks if c and c[0] == 0x4C]

View File

@@ -1,52 +1,22 @@
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
"""Device status WebSocket broadcasts (ESP-NOW has no live TCP session)."""
import asyncio
import json
import threading
from typing import Any, Set
# Threading lock: safe across asyncio tasks and avoids binding asyncio.Lock to the wrong loop.
_clients_lock = threading.Lock()
_clients: Set[Any] = set()
_ws_clients: set = set()
async def register_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.add(ws)
async def register_device_status_ws(ws):
_ws_clients.add(ws)
async def unregister_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.discard(ws)
async def unregister_device_status_ws(ws):
_ws_clients.discard(ws)
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
from models.wifi_ws_clients import normalize_tcp_peer_ip
ip = normalize_tcp_peer_ip(ip)
if not ip:
return
msg = json.dumps({"type": "device_tcp", "ip": ip, "connected": bool(connected)})
with _clients_lock:
targets = list(_clients)
dead = []
for ws in targets:
try:
await ws.send(msg)
except Exception as exc:
dead.append(ws)
print(f"[device_status_broadcaster] ws.send failed: {exc!r}")
if dead:
with _clients_lock:
for ws in dead:
_clients.discard(ws)
async def broadcast_device_tcp_snapshot_to(ws):
await ws.send(json.dumps({"type": "device_tcp_snapshot", "devices": {}}))
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
from models import wifi_ws_clients as tcp
ips = tcp.list_connected_ips()
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
try:
await ws.send(msg)
except Exception as exc:
print(f"[device_status_broadcaster] snapshot send failed: {exc!r}")
async def broadcast_device_tcp_status(mac: str, connected: bool):
pass

View File

@@ -1,70 +1,73 @@
"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers)."""
"""Deliver binary ESP-NOW messages via bridge WebSocket."""
import asyncio
import json
from typing import List, Optional, Union
from models.device import normalize_mac
from models.wifi_ws_clients import send_json_line_to_ip
from util.binary_driver_messages import build_preset_cmd_chunks, v1_dict_to_cmd_packet
from util.espnow_wire import BROADCAST_MAC, pack_group_cmd
# Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
_SPLIT_MODE = "split"
_BROADCAST_MAC_HEX = "ffffffffffff"
_BROADCAST_HEX = "ffffffffffff"
def _split_serial_envelope(inner_json_str, peer_hex_list):
"""One UART frame: broadcast dest + JSON {m:split, peers:[hex,...], body:<object>}."""
body = json.loads(inner_json_str)
env = {"m": _SPLIT_MODE, "peers": list(peer_hex_list), "body": body}
return json.dumps(env, separators=(",", ":"))
async def deliver_binary_packets(
sender,
packets: List[bytes],
target_macs: Optional[List[str]] = None,
*,
delay_s: float = 0.1,
) -> int:
"""Send binary CMD packets unicast per MAC or broadcast when no targets."""
if not packets:
return 0
deliveries = 0
if not target_macs:
for pkt in packets:
if await sender.send(pkt, addr=_BROADCAST_HEX):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
seen = set()
ordered: List[str] = []
for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None
if not m or m in seen:
continue
seen.add(m)
ordered.append(m)
for pkt in packets:
for mac in ordered:
if await sender.send(pkt, addr=mac):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
def _wifi_message_for_device(msg, device_name):
"""
For Wi-Fi WebSocket fanout, narrow a v1 select map to a single device name.
Returns the original message when no narrowing applies.
"""
if not device_name:
return msg
try:
body = json.loads(msg)
except Exception:
return msg
if not isinstance(body, dict):
return msg
select = body.get("select")
if not isinstance(select, dict):
return msg
if device_name not in select:
return msg
body["select"] = {device_name: select[device_name]}
return json.dumps(body, separators=(",", ":"))
async def deliver_group_binary_packets(
sender,
group_id: str,
packets: List[bytes],
*,
delay_s: float = 0.1,
) -> int:
"""Broadcast GROUP_CMD packets (one ESP-NOW send per packet)."""
from util.espnow_wire import parse_cmd
def _combine_preset_chunks_for_wifi(chunk_messages):
"""Merge chunked v1 preset messages into one v1 JSON string for Wi-Fi."""
merged_presets = {}
save_flag = False
default_id = None
for msg in chunk_messages:
deliveries = 0
for pkt in packets:
env, save = parse_cmd(pkt)
if env is None:
continue
try:
body = json.loads(msg)
except Exception:
g_pkt = pack_group_cmd(str(group_id), env, save=save)
except ValueError:
continue
if not isinstance(body, dict):
continue
presets = body.get("presets")
if isinstance(presets, dict):
merged_presets.update(presets)
if body.get("save"):
save_flag = True
if body.get("default") is not None:
default_id = body.get("default")
out = {"v": "1", "presets": merged_presets}
if save_flag:
out["save"] = True
if default_id is not None:
out["default"] = default_id
return json.dumps(out, separators=(",", ":"))
if await sender.send(g_pkt, addr=_BROADCAST_HEX):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
async def deliver_preset_broadcast_then_per_device(
@@ -76,11 +79,24 @@ async def deliver_preset_broadcast_then_per_device(
delay_s=0.1,
):
"""
Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each
Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message
(unicast serial or WebSocket) with targets=[device name] for each registry entry.
chunk_messages: list of v1 JSON strings OR binary CMD bytes.
Converts JSON strings to binary when needed.
"""
if not chunk_messages:
packets: List[bytes] = []
for msg in chunk_messages:
if isinstance(msg, (bytes, bytearray)):
packets.append(bytes(msg))
else:
import json
try:
body = json.loads(msg)
except Exception:
continue
if isinstance(body, dict):
packets.append(v1_dict_to_cmd_packet(body))
if not packets:
return 0
seen = set()
@@ -92,30 +108,9 @@ async def deliver_preset_broadcast_then_per_device(
seen.add(m)
ordered.append(m)
wifi_ips = []
for mac in ordered:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi" and doc.get("address"):
wifi_ips.append(str(doc["address"]).strip())
deliveries = 0
wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages)
for msg in chunk_messages:
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)]
results = await asyncio.gather(*tasks, return_exceptions=True)
if results and results[0] is True:
deliveries += 1
await asyncio.sleep(delay_s)
for ip in wifi_ips:
if not ip:
continue
try:
if await send_json_line_to_ip(ip, wifi_combined_msg):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
await asyncio.sleep(delay_s)
deliveries = await deliver_binary_packets(
sender, packets, ordered, delay_s=delay_s
)
if default_id:
did = str(default_id)
@@ -123,20 +118,9 @@ async def deliver_preset_broadcast_then_per_device(
doc = devices_model.read(mac) or {}
name = str(doc.get("name") or "").strip() or mac
body = {"v": "1", "default": did, "save": True, "targets": [name]}
out = json.dumps(body, separators=(",", ":"))
if doc.get("transport") == "wifi" and doc.get("address"):
ip = str(doc["address"]).strip()
try:
if await send_json_line_to_ip(ip, out):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
else:
try:
await sender.send(out, addr=mac)
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default serial failed: {e!r}")
pkt = v1_dict_to_cmd_packet(body)
if await sender.send(pkt, addr=mac):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
@@ -144,26 +128,29 @@ async def deliver_preset_broadcast_then_per_device(
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1):
"""
Send each message string to the bridge and/or Wi-Fi WebSocket clients.
If target_macs is None or empty: one serial send per message (default/broadcast address).
Otherwise: Wi-Fi uses WebSocket in parallel. Multiple ESP-NOW peers are sent in **one** serial
write to the ESP32 (broadcast + split envelope); the bridge unicasts ``body`` to each
peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial
tasks run together in one asyncio.gather.
Returns (delivery_count, chunk_count) where chunk_count is len(messages).
Convert v1 JSON message strings to binary CMD packets and deliver.
Returns (delivery_count, chunk_count).
"""
if not messages:
packets: List[bytes] = []
import json
for msg in messages:
if isinstance(msg, (bytes, bytearray)):
packets.append(bytes(msg))
continue
try:
body = json.loads(msg)
except Exception:
continue
if isinstance(body, dict):
packets.append(v1_dict_to_cmd_packet(body))
if not packets:
return 0, 0
if not target_macs:
deliveries = 0
for msg in messages:
await sender.send(msg)
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries, len(messages)
n = await deliver_binary_packets(sender, packets, None, delay_s=delay_s)
return n, len(packets)
seen = set()
ordered_macs = []
@@ -174,51 +161,5 @@ async def deliver_json_messages(sender, messages, target_macs, devices_model, de
seen.add(m)
ordered_macs.append(m)
deliveries = 0
for msg in messages:
wifi_tasks = []
espnow_hex = []
for mac in ordered_macs:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi":
ip = doc.get("address")
if ip:
name = str(doc.get("name") or "").strip()
wifi_msg = _wifi_message_for_device(msg, name)
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
else:
espnow_hex.append(mac)
tasks = []
espnow_peer_count = 0
if len(espnow_hex) > 1:
tasks.append(
sender.send(
_split_serial_envelope(msg, espnow_hex),
addr=_BROADCAST_MAC_HEX,
)
)
espnow_peer_count = len(espnow_hex)
elif len(espnow_hex) == 1:
tasks.append(sender.send(msg, addr=espnow_hex[0]))
espnow_peer_count = 1
tasks.extend(wifi_tasks)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
n_serial = len(tasks) - len(wifi_tasks)
for i, r in enumerate(results):
if i < n_serial:
if r is True:
deliveries += espnow_peer_count
elif isinstance(r, Exception):
print(f"[driver_delivery] serial delivery failed: {r!r}")
else:
if r is True:
deliveries += 1
elif isinstance(r, Exception):
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
await asyncio.sleep(delay_s)
return deliveries, len(messages)
n = await deliver_binary_packets(sender, packets, ordered_macs, delay_s=delay_s)
return n, len(packets)

View File

@@ -0,0 +1,70 @@
"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers."""
from __future__ import annotations
from models.device import Device, normalize_mac # noqa: F401 — re-export for callers
from models.group import Group
from models.bridge_ws_client import get_bridge_client
from util.espnow_wire import mac_bytes_to_hex, pack_groups, parse_announce
from util.groups_for_device import groups_for_mac
async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
info = parse_announce(packet)
if not info:
return
mac_hex = mac_bytes_to_hex(peer_mac)
if not mac_hex:
return
devices = Device()
did, persisted = devices.upsert_espnow_announced(
mac_hex,
info["name"],
device_type=info.get("device_type", "led"),
num_leds=info.get("num_leds"),
color_order=info.get("color_order"),
startup_mode=info.get("startup_mode"),
brightness=info.get("brightness"),
)
if not did:
return
if persisted:
print(f"[espnow] registered mac={did} name={info['name']!r}")
groups = Group()
gids = groups_for_mac(did, groups)
groups_pkt = pack_groups(gids)
client = get_bridge_client()
if client is None:
print("[espnow] bridge client not configured; groups not sent")
return
ok = await client.send_espnow(groups_pkt, peer_mac=peer_mac)
if ok:
print(f"[espnow] groups -> {did}: {gids}")
else:
print(f"[espnow] groups send failed for {did}")
async def push_groups_for_group_devices(gdoc: dict) -> None:
"""Refresh GROUPS on every MAC listed on a group document."""
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
for mac in mac_list:
m = normalize_mac(str(mac))
if m:
await push_groups_to_mac(m)
async def push_groups_to_mac(mac_hex: str) -> bool:
"""Re-send GROUPS packet to one device (after group membership change)."""
mac = normalize_mac(mac_hex)
if not mac:
return False
client = get_bridge_client()
if client is None:
return False
groups = Group()
gids = groups_for_mac(mac, groups)
pkt = pack_groups(gids)
return await client.send_espnow(pkt, peer_mac=bytes.fromhex(mac))

291
src/util/espnow_wire.py Normal file
View File

@@ -0,0 +1,291 @@
"""
ESP-NOW wire format: magic header + message types, Pi↔bridge WebSocket framing.
See docs/espnow-binary-protocol.md.
"""
from __future__ import annotations
import struct
from typing import Any, Dict, List, Optional, Tuple
from util.binary_envelope import (
BINARY_ENVELOPE_VERSION_2,
pack_binary_envelope_v2,
parse_binary_envelope_v2,
)
WIRE_MAGIC = 0x4C
MAX_ESPNOW_PAYLOAD = 250
MSG_ANNOUNCE = 0x01
MSG_GROUPS = 0x02
MSG_CMD = 0x03
MSG_GROUP_CMD = 0x04
MSG_BRIDGE_CH = 0x10
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
WS_FLAG_BROADCAST = 0x01
COLOR_ORDER_TO_ENUM = {
"rgb": 0,
"rbg": 1,
"grb": 2,
"gbr": 3,
"brg": 4,
"bgr": 5,
}
ENUM_TO_COLOR_ORDER = {v: k for k, v in COLOR_ORDER_TO_ENUM.items()}
STARTUP_MODE_TO_ENUM = {"default": 0, "last": 1, "off": 2}
ENUM_TO_STARTUP_MODE = {v: k for k, v in STARTUP_MODE_TO_ENUM.items()}
def normalize_mac_bytes(mac: Any) -> Optional[bytes]:
if mac is None:
return None
if isinstance(mac, (bytes, bytearray)) and len(mac) == 6:
return bytes(mac)
s = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return bytes.fromhex(s)
return None
def mac_bytes_to_hex(mac: bytes) -> str:
return mac.hex()
def _pack_header(msg_type: int, body: bytes) -> bytes:
pkt = bytes([WIRE_MAGIC, msg_type]) + body
if len(pkt) > MAX_ESPNOW_PAYLOAD:
raise ValueError(f"ESP-NOW packet {len(pkt)} exceeds {MAX_ESPNOW_PAYLOAD}")
return pkt
def pack_announce(
*,
name: str,
num_leds: int,
color_order: str = "rgb",
startup_mode: str = "default",
brightness: int = 32,
device_type: int = 0,
) -> bytes:
name_b = name.encode("utf-8")
if len(name_b) > 250:
raise ValueError("name too long")
co = COLOR_ORDER_TO_ENUM.get(str(color_order).lower(), 0)
sm = STARTUP_MODE_TO_ENUM.get(str(startup_mode).lower(), 0)
body = (
bytes([len(name_b)])
+ name_b
+ struct.pack("<H", max(0, min(65535, int(num_leds))))
+ bytes([co & 7, sm & 3, max(0, min(255, int(brightness))), device_type & 255])
)
return _pack_header(MSG_ANNOUNCE, body)
def parse_announce(payload: bytes) -> Optional[Dict[str, Any]]:
"""Parse full ESP-NOW packet or body-only after type byte."""
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_ANNOUNCE:
return None
body = payload[2:]
else:
body = payload
off = 0
if off + 1 > len(body):
return None
nl = body[off]
off += 1
if off + nl + 5 > len(body):
return None
name = body[off : off + nl].decode("utf-8")
off += nl
num_leds = struct.unpack_from("<H", body, off)[0]
off += 2
co, sm, br, dt = body[off], body[off + 1], body[off + 2], body[off + 3]
return {
"name": name,
"num_leds": num_leds,
"color_order": ENUM_TO_COLOR_ORDER.get(co, "rgb"),
"startup_mode": ENUM_TO_STARTUP_MODE.get(sm, "default"),
"brightness": br,
"device_type": "led" if dt == 0 else str(dt),
}
def pack_groups(group_ids: List[str]) -> bytes:
parts = [bytes([min(255, len(group_ids))])]
for gid in group_ids[:255]:
gb = str(gid).encode("utf-8")
if len(gb) > 250:
raise ValueError("group id too long")
parts.append(bytes([len(gb)]))
parts.append(gb)
return _pack_header(MSG_GROUPS, b"".join(parts))
def parse_groups(payload: bytes) -> Optional[List[str]]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_GROUPS:
return None
body = payload[2:]
else:
body = payload
if not body:
return []
off = 0
if off + 1 > len(body):
return None
count = body[off]
off += 1
out: List[str] = []
for _ in range(count):
if off + 1 > len(body):
return None
gl = body[off]
off += 1
if off + gl > len(body):
return None
out.append(body[off : off + gl].decode("utf-8"))
off += gl
return out
def cmd_envelope_size(envelope: bytes) -> int:
from util.binary_envelope import HEADER_LEN
if len(envelope) < HEADER_LEN:
return len(envelope)
lp, ls, ld = envelope[2], envelope[3], envelope[4]
return HEADER_LEN + lp + ls + ld
def pack_cmd(envelope: bytes, *, save: bool = False) -> bytes:
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
raise ValueError("CMD envelope must be v2 binary")
need = cmd_envelope_size(envelope)
body = envelope[:need]
if save:
body = body + bytes([1])
if len(body) + 2 > MAX_ESPNOW_PAYLOAD:
raise ValueError("CMD envelope too large for ESP-NOW")
return _pack_header(MSG_CMD, body)
def pack_cmd_from_kwargs(*, save: bool = False, **kwargs: Any) -> bytes:
return pack_cmd(pack_binary_envelope_v2(**kwargs), save=save)
def parse_cmd(payload: bytes) -> Tuple[Optional[bytes], bool]:
"""Return (v2 envelope bytes, save_flag) inside CMD packet."""
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_CMD:
return None, False
env = payload[2:]
if not env:
return None, False
need = cmd_envelope_size(env)
if need > len(env):
return None, False
save = len(env) > need and env[need] == 1
return bytes(env[:need]), save
def parse_cmd_as_v1_dict(payload: bytes) -> Optional[Dict[str, Any]]:
env, save = parse_cmd(payload)
if env is None:
return None
data = parse_binary_envelope_v2(env)
if data is None:
return None
if save:
data["save"] = True
return data
def pack_group_cmd(group_id: str, envelope: bytes, *, save: bool = False) -> bytes:
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
raise ValueError("GROUP_CMD envelope must be v2 binary")
gid_b = str(group_id).encode("utf-8")
if len(gid_b) > 250:
raise ValueError("group id too long")
need = cmd_envelope_size(envelope)
env = envelope[:need]
if save:
env = env + bytes([1])
body = bytes([len(gid_b)]) + gid_b + env
return _pack_header(MSG_GROUP_CMD, body)
def pack_group_cmd_from_kwargs(group_id: str, **kwargs: Any) -> bytes:
return pack_group_cmd(group_id, pack_binary_envelope_v2(**kwargs))
def parse_group_cmd(payload: bytes) -> Optional[Tuple[str, bytes]]:
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_GROUP_CMD:
return None
body = payload[2:]
if not body:
return None
gl = body[0]
if 1 + gl > len(body):
return None
gid = body[1 : 1 + gl].decode("utf-8")
env = body[1 + gl :]
return gid, bytes(env)
def pack_bridge_channel(channel: int) -> bytes:
ch = max(1, min(11, int(channel)))
return _pack_header(MSG_BRIDGE_CH, bytes([ch]))
def parse_bridge_channel(payload: bytes) -> Optional[int]:
if len(payload) < 3 or payload[0] != WIRE_MAGIC or payload[1] != MSG_BRIDGE_CH:
return None
return int(payload[2])
def wire_msg_type(payload: bytes) -> Optional[int]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
return int(payload[1])
return None
def pack_ws_downlink(
espnow_packet: bytes,
*,
peer_mac: Optional[Any] = None,
broadcast: bool = False,
) -> bytes:
flags = WS_FLAG_BROADCAST if broadcast else 0
if broadcast:
peer = BROADCAST_MAC
else:
peer = normalize_mac_bytes(peer_mac)
if peer is None:
raise ValueError("peer MAC required for unicast downlink")
return bytes([flags]) + peer + espnow_packet
def pack_ws_uplink(peer_mac: bytes, espnow_packet: bytes) -> bytes:
peer = normalize_mac_bytes(peer_mac)
if peer is None:
raise ValueError("invalid peer MAC")
return bytes([0]) + peer + espnow_packet
def parse_ws_frame(frame: bytes) -> Tuple[bytes, bytes, bool]:
"""
Returns (peer_mac_6bytes, espnow_packet, is_broadcast_dest).
"""
if len(frame) < 8:
raise ValueError("WS 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

View File

@@ -0,0 +1,23 @@
"""Resolve group membership for a device MAC."""
from models.device import normalize_mac
def groups_for_mac(mac_hex: str, groups_model) -> list[str]:
"""Return group ids (string keys) that list this device MAC."""
mac = normalize_mac(mac_hex)
if not mac:
return []
out: list[str] = []
for gid in groups_model.list():
doc = groups_model.read(gid)
if not isinstance(doc, dict):
continue
devs = doc.get("devices")
if not isinstance(devs, list):
continue
for d in devs:
if normalize_mac(str(d)) == mac:
out.append(str(gid))
break
return out