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:
@@ -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 Wi‑Fi 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"}
|
||||
|
||||
@@ -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 Wi‑Fi defaults to every Wi‑Fi 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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
238
src/main.py
238
src/main.py
@@ -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()
|
||||
|
||||
142
src/models/bridge_ws_client.py
Normal file
142
src/models/bridge_ws_client.py
Normal 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
|
||||
@@ -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**,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -52,31 +52,9 @@ class Settings(dict):
|
||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||
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 (0–255); 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:
|
||||
|
||||
62
src/util/binary_driver_messages.py
Normal file
62
src/util/binary_driver_messages.py
Normal 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
70
src/util/espnow_registry.py
Normal file
70
src/util/espnow_registry.py
Normal 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
291
src/util/espnow_wire.py
Normal 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
|
||||
23
src/util/groups_for_device.py
Normal file
23
src/util/groups_for_device.py
Normal 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
|
||||
Reference in New Issue
Block a user