"""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:}.""" 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)