225 lines
7.5 KiB
Python
225 lines
7.5 KiB
Python
"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers)."""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
from models.device import normalize_mac
|
|
from models.wifi_ws_clients import send_json_line_to_ip
|
|
|
|
# Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
|
|
_SPLIT_MODE = "split"
|
|
_BROADCAST_MAC_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=(",", ":"))
|
|
|
|
|
|
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=(",", ":"))
|
|
|
|
|
|
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:
|
|
try:
|
|
body = json.loads(msg)
|
|
except Exception:
|
|
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=(",", ":"))
|
|
|
|
|
|
async def deliver_preset_broadcast_then_per_device(
|
|
sender,
|
|
chunk_messages,
|
|
target_macs,
|
|
devices_model,
|
|
default_id,
|
|
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.
|
|
"""
|
|
if not chunk_messages:
|
|
return 0
|
|
|
|
seen = set()
|
|
ordered = []
|
|
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)
|
|
|
|
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)
|
|
|
|
if default_id:
|
|
did = str(default_id)
|
|
for mac in ordered:
|
|
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}")
|
|
await asyncio.sleep(delay_s)
|
|
|
|
return deliveries
|
|
|
|
|
|
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).
|
|
"""
|
|
if not messages:
|
|
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)
|
|
|
|
seen = set()
|
|
ordered_macs = []
|
|
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_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)
|