feat(controller): migrate wifi drivers from tcp to websocket clients

This commit is contained in:
2026-04-14 23:13:26 +12:00
parent f5a7b42e7c
commit 96712dda88
19 changed files with 1195 additions and 673 deletions

View File

@@ -6,7 +6,7 @@ from models.device import (
validate_device_type,
)
from models.transport import get_current_sender
from models.tcp_clients import (
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
@@ -56,8 +56,8 @@ devices = Device()
def _device_live_connected(dev_dict):
"""
Wi-Fi: whether a TCP client is registered for this device's address (IP).
ESP-NOW: None (no TCP session on the Pi for that transport).
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":
@@ -114,7 +114,7 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
@controller.get("")
async def list_devices(request):
"""List all devices (includes ``connected`` for live Wi-Fi TCP presence)."""
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
@@ -125,7 +125,7 @@ async def list_devices(request):
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi TCP presence)."""
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
dev = devices.read(id)
if dev:
return json.dumps(_device_json_with_live_status(dev)), 200, {

View File

@@ -1,7 +1,7 @@
from microdot import Microdot
from models.pattern import Pattern
from models.device import Device
from models.tcp_clients import send_json_line_to_ip
from models.wifi_ws_clients import send_json_line_to_ip
from util.driver_patterns import (
driver_patterns_dir,
is_firmware_builtin_pattern_module,

View File

@@ -23,7 +23,7 @@ import controllers.settings as settings_controller
import controllers.device as device_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import tcp_clients as tcp_client_registry
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,
@@ -33,82 +33,8 @@ from util.device_status_broadcaster import (
_tcp_device_lock = threading.Lock()
# Wi-Fi drivers send one hello line then stay quiet; periodic outbound data makes dead peers
# fail drain() within this interval (keepalive alone is often slow or ineffective).
TCP_LIVENESS_PING_INTERVAL_S = 12.0
DISCOVERY_UDP_PORT = 8766
# Keepalive or lossy Wi-Fi can still surface OSError(110) / TimeoutError on recv or wait_closed.
_TCP_PEER_GONE = (
BrokenPipeError,
ConnectionResetError,
ConnectionAbortedError,
ConnectionRefusedError,
TimeoutError,
OSError,
)
def _tcp_socket_from_writer(writer):
sock = writer.get_extra_info("socket")
if sock is not None:
return sock
transport = getattr(writer, "transport", None)
if transport is not None:
return transport.get_extra_info("socket")
return None
def _enable_tcp_keepalive(writer) -> None:
"""
Detect vanished peers (power off, Wi-Fi drop) without waiting for a send() failure.
Linux: shorten time before the first keepalive probe; other platforms: SO_KEEPALIVE only.
"""
sock = _tcp_socket_from_writer(writer)
if sock is None:
return
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
except OSError:
return
if hasattr(socket, "TCP_KEEPIDLE"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 120)
except OSError:
pass
if hasattr(socket, "TCP_KEEPINTVL"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
except OSError:
pass
if hasattr(socket, "TCP_KEEPCNT"):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
except OSError:
pass
# Do not set TCP_USER_TIMEOUT: a short value causes Errno 110 on recv for Wi-Fi peers
# when ACKs are delayed (ESP power save, lossy links). Liveness pings already clear dead
# sessions via drain().
async def _tcp_liveness_ping_loop(writer, peer_ip: str) -> None:
"""Send a bare newline so ``drain()`` fails soon after the peer disappears."""
while True:
await asyncio.sleep(TCP_LIVENESS_PING_INTERVAL_S)
if writer.is_closing():
return
try:
writer.write(b"\n")
await writer.drain()
except Exception as exc:
print(f"[TCP] liveness ping failed {peer_ip!r}: {exc!r}")
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
try:
writer.close()
except Exception:
pass
return
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
@@ -116,10 +42,10 @@ def _register_udp_device_sync(
with _tcp_device_lock:
try:
d = Device()
did = d.upsert_wifi_tcp_client(
did, persisted = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type
)
if did:
if did and persisted:
print(
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
@@ -155,6 +81,8 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
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:
@@ -163,6 +91,109 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
print(f"[UDP] echo send failed: {e!r}")
def _prime_wifi_outbound_driver_connections() -> None:
"""
For each WiFi device in the registry with a usable IPv4, start (or keep) the
outbound WebSocket task. The client loop reconnects automatically if the link
drops. Presets are not pushed automatically; use Send Presets / profile apply.
"""
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 _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
"""
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
UDP discovery port so the device can announce itself and we can reconnect.
"""
try:
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
except (TypeError, ValueError):
interval = 10.0
if interval <= 0:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
loop = asyncio.get_running_loop()
try:
while True:
await asyncio.sleep(interval)
if udp_holder.get("closing"):
break
try:
dev = Device()
except Exception as e:
print(f"[hello] device list failed: {e!r}")
continue
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
if tcp_client_registry.tcp_client_connected(ip):
continue
name = (doc.get("name") or "").strip()
mac = normalize_mac(doc.get("id") or _mac_key)
if not name or not mac:
continue
line = (
json.dumps(
{"m": "hello", "device_name": name, "mac": mac},
separators=(",", ":"),
)
+ "\n"
)
try:
await loop.sock_sendto(
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
)
except OSError as e:
print(f"[hello] UDP to {ip!r} failed: {e!r}")
finally:
try:
sock.close()
except OSError:
pass
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
@@ -189,87 +220,6 @@ async def _run_udp_discovery_server(udp_holder=None) -> None:
pass
async def _handle_tcp_client(reader, writer):
"""Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge."""
peer = writer.get_extra_info("peername")
peer_ip = peer[0] if peer else ""
peer_label = f"{peer_ip}:{peer[1]}" if peer and len(peer) > 1 else peer_ip or "?"
print(f"[TCP] client connected {peer_label}")
_enable_tcp_keepalive(writer)
tcp_client_registry.register_tcp_writer(peer_ip, writer)
ping_task = asyncio.create_task(_tcp_liveness_ping_loop(writer, peer_ip))
sender = get_current_sender()
buf = b""
try:
while True:
try:
chunk = await reader.read(4096)
except asyncio.CancelledError:
raise
except _TCP_PEER_GONE as e:
print(f"[TCP] read ended ({peer_label}): {e!r}")
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
break
if not chunk:
break
buf += chunk
while b"\n" in buf:
raw_line, buf = buf.split(b"\n", 1)
line = raw_line.strip()
if not line:
continue
try:
text = line.decode("utf-8")
except UnicodeDecodeError:
print(
f"[TCP] recv {peer_label} (non-UTF-8, {len(line)} bytes): {line!r}"
)
continue
print(f"[TCP] recv {peer_label}: {text}")
try:
parsed = json.loads(text)
except json.JSONDecodeError:
if sender:
try:
await sender.send(text)
except Exception:
pass
continue
if isinstance(parsed, dict):
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
if sender:
try:
await sender.send(payload, addr=addr)
except Exception as e:
print(f"TCP forward to bridge failed: {e}")
elif sender:
try:
await sender.send(text)
except Exception:
pass
finally:
# Drop registry + broadcast connected:false before awaiting ping/close so the UI
# does not stay green if ping or wait_closed blocks on a timed-out peer.
outcome = tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
if outcome == "superseded":
print(
f"[TCP] TCP session ended (same IP already has a newer connection): {peer_label}"
)
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
try:
writer.close()
await writer.wait_closed()
except asyncio.CancelledError:
raise
except _TCP_PEER_GONE:
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
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:
@@ -285,23 +235,6 @@ async def _send_bridge_wifi_channel(settings, sender):
print(f"[startup] bridge channel message failed: {e}")
async def _run_tcp_server(settings, tcp_holder=None):
if not settings.get("tcp_enabled", True):
print("TCP server disabled (tcp_enabled=false)")
return
port = int(settings.get("tcp_port", 8765))
server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port)
print(f"TCP server listening on 0.0.0.0:{port}")
if tcp_holder is not None:
tcp_holder["server"] = server
try:
async with server:
await server.serve_forever()
finally:
if tcp_holder is not None:
tcp_holder.pop("server", None)
async def main(port=80):
settings = Settings()
print(settings)
@@ -341,6 +274,7 @@ async def main(port=80):
app.mount(settings_controller.controller, '/settings')
app.mount(device_controller.controller, '/devices')
tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
# Serve index.html at root (cwd is src/ when run via pipenv run run)
@@ -407,11 +341,11 @@ async def main(port=80):
# Touch Device singleton early so db/device.json exists before first TCP hello.
# 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()
tcp_holder = {}
udp_holder = {"closing": False}
loop = asyncio.get_running_loop()
@@ -424,9 +358,7 @@ async def main(port=80):
u.close()
except OSError:
pass
s = tcp_holder.get("server")
if s is not None:
s.close()
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
app.shutdown()
@@ -439,22 +371,18 @@ async def main(port=80):
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
# never starts, which clears Wi-Fi presence dots.
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_tcp_server(settings, tcp_holder),
_run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
tcp_p = int(settings.get("tcp_port", 8765))
print(
f"[server] bind failed (address already in use): {e!s}\n"
f"[server] HTTP is configured for port {port} (env PORT); "
f"Wi-Fi LED drivers use tcp_port {tcp_p}. "
f"[server] HTTP is configured for port {port} (env PORT). "
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
)
raise

View File

@@ -237,16 +237,19 @@ class Device(Model):
"""
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
**address** (peer IP), and optionally **type** from the client hello when valid.
Returns ``(mac_hex | None, persisted)`` where **persisted** is True iff ``save()``
ran (new row or field changes). Duplicate hellos with identical data are no-ops.
"""
mac_hex = normalize_mac(mac)
if not mac_hex:
return None
return None, False
name = (device_name or "").strip()
if not name:
return None
return None, False
ip = normalize_address_for_transport(peer_ip, "wifi")
if not ip:
return None
return None, False
resolved_type = None
if device_type is not None:
try:
@@ -254,7 +257,8 @@ class Device(Model):
except ValueError:
resolved_type = None
if mac_hex in self:
merged = dict(self[mac_hex])
prev = self[mac_hex]
merged = dict(prev)
merged["name"] = name
if resolved_type is not None:
merged["type"] = resolved_type
@@ -263,9 +267,11 @@ class Device(Model):
merged["transport"] = "wifi"
merged["address"] = ip
merged["id"] = mac_hex
if merged == prev:
return mac_hex, False
self[mac_hex] = merged
self.save()
return mac_hex
return mac_hex, True
self[mac_hex] = {
"id": mac_hex,
"name": name,
@@ -276,4 +282,4 @@ class Device(Model):
"zones": [],
}
self.save()
return mac_hex
return mac_hex, True

View File

@@ -1,115 +0,0 @@
"""Track connected Wi-Fi LED drivers (TCP clients) for outbound JSON lines."""
import asyncio
_writers = {}
def prune_stale_tcp_writers() -> None:
"""Remove writers that are already closing so the UI does not stay online."""
stale = [(ip, w) for ip, w in list(_writers.items()) if w.is_closing()]
for ip, w in stale:
unregister_tcp_writer(ip, w)
def normalize_tcp_peer_ip(ip: str) -> str:
"""Match asyncio peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
s = str(ip).strip()
if s.lower().startswith("::ffff:"):
s = s[7:]
return s
# Optional ``async def (ip: str, connected: bool) -> None`` set from ``main``.
_tcp_status_broadcast = None
def set_tcp_status_broadcaster(coro) -> None:
global _tcp_status_broadcast
_tcp_status_broadcast = coro
def _schedule_tcp_status_broadcast(ip: str, connected: bool) -> None:
fn = _tcp_status_broadcast
if not fn:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
try:
loop.create_task(fn(ip, connected))
except Exception:
pass
def register_tcp_writer(peer_ip: str, writer) -> None:
if not peer_ip:
return
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return
old = _writers.get(key)
_writers[key] = writer
_schedule_tcp_status_broadcast(key, True)
if old is not None and old is not writer:
try:
old.close()
except Exception:
pass
def unregister_tcp_writer(peer_ip: str, writer=None) -> str:
"""
Remove the writer for peer_ip. If ``writer`` is given, only pop when it is still
the registered instance (avoids a replaced TCP session removing the new one).
Returns ``removed`` (cleared live session + UI offline), ``noop`` (already gone),
or ``superseded`` (this writer is not the registered one for that IP).
"""
if not peer_ip:
return "noop"
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return "noop"
current = _writers.get(key)
if writer is not None:
if current is None:
return "noop"
if current is not writer:
return "superseded"
had = key in _writers
if had:
_writers.pop(key, None)
_schedule_tcp_status_broadcast(key, False)
print(f"[TCP] device disconnected: {key}")
return "removed"
return "noop"
def list_connected_ips():
"""IPs with an active TCP writer (for UI snapshot)."""
prune_stale_tcp_writers()
return list(_writers.keys())
def tcp_client_connected(ip: str) -> bool:
"""True if a Wi-Fi driver is connected on this IP (TCP writer registered)."""
prune_stale_tcp_writers()
key = normalize_tcp_peer_ip(ip)
return bool(key and key in _writers)
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
"""Send one newline-terminated JSON message to a connected TCP client."""
ip = normalize_tcp_peer_ip(ip)
writer = _writers.get(ip)
if not writer:
return False
try:
line = json_str if json_str.endswith("\n") else json_str + "\n"
writer.write(line.encode("utf-8"))
await writer.drain()
return True
except Exception as exc:
print(f"[TCP] send to {ip} failed: {exc}")
unregister_tcp_writer(ip, writer)
return False

View File

@@ -0,0 +1,281 @@
"""Outbound WebSocket clients to Wi-Fi LED drivers (firmware serves ``/ws`` on device)."""
from __future__ import annotations
import asyncio
import errno
import json
import traceback
import websockets
from websockets.exceptions import ConnectionClosed
_connections: dict[str, object] = {}
_send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None
_tcp_status_broadcast = None
def set_settings(settings) -> None:
global _settings
_settings = settings
def set_tcp_status_broadcaster(coro) -> None:
global _tcp_status_broadcast
_tcp_status_broadcast = coro
def _schedule_status_broadcast(ip: str, connected: bool) -> None:
fn = _tcp_status_broadcast
if not fn:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
try:
loop.create_task(fn(ip, connected))
except Exception:
pass
def _benign_ws_connect_failure(exc: BaseException) -> bool:
"""True for common \"driver down / no route\" errors while dialling the WebSocket."""
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
return True
if isinstance(exc, ConnectionRefusedError):
return True
if not isinstance(exc, OSError):
return False
en = exc.errno
if en is None:
return False
codes = {errno.ECONNREFUSED, errno.ETIMEDOUT}
for name in ("EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EADDRNOTAVAIL"):
if hasattr(errno, name):
codes.add(getattr(errno, name))
return en in codes
def normalize_tcp_peer_ip(ip: str) -> str:
"""Match peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
s = str(ip).strip()
if s.lower().startswith("::ffff:"):
s = s[7:]
return s
def _ws_open(ws) -> bool:
try:
return ws.close_code is None
except Exception:
return False
def prune_stale_tcp_writers() -> None:
"""Drop closed WebSocket entries (name kept for callers)."""
stale = [ip for ip, ws in list(_connections.items()) if not _ws_open(ws)]
for ip in stale:
_connections.pop(ip, None)
_schedule_status_broadcast(ip, False)
def _register_ws(ip: str, ws) -> None:
key = normalize_tcp_peer_ip(ip)
if not key:
return
_connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks:
_send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True)
print(f"[WS] driver connected {key!r}")
def unregister_tcp_writer(peer_ip: str, ws=None) -> str:
"""
Remove the WebSocket for peer_ip. If ``ws`` is given, only pop when it is still
the registered instance.
Returns ``removed``, ``noop``, or ``superseded`` (same contract as former TCP registry).
"""
if not peer_ip:
return "noop"
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return "noop"
current = _connections.get(key)
if ws is not None:
if current is None:
return "noop"
if current is not ws:
return "superseded"
had = key in _connections
if had:
_connections.pop(key, None)
_schedule_status_broadcast(key, False)
print(f"[WS] driver disconnected: {key}")
return "removed"
return "noop"
def list_connected_ips():
"""IPs with an active outbound WebSocket to the driver."""
prune_stale_tcp_writers()
return list(_connections.keys())
def tcp_client_connected(ip: str) -> bool:
"""True if the controller has an outbound WebSocket to this driver IP."""
prune_stale_tcp_writers()
key = normalize_tcp_peer_ip(ip)
return bool(key and key in _connections)
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
"""Send one JSON text frame (v1 line; trailing newline stripped for WebSocket)."""
ip = normalize_tcp_peer_ip(ip)
ws = _connections.get(ip)
if ws is None or not _ws_open(ws):
return False
text = json_str.rstrip("\n")
lock = _send_locks.setdefault(ip, asyncio.Lock())
try:
async with lock:
await ws.send(text)
return True
except Exception as exc:
print(f"[WS] send to {ip} failed: {exc}")
unregister_tcp_writer(ip, ws)
return False
async def _recv_forward_loop(ip: str, ws) -> None:
from models.transport import get_current_sender
sender = get_current_sender()
async for message in ws:
if isinstance(message, bytes):
try:
text = message.decode("utf-8")
except UnicodeDecodeError:
print(f"[WS] recv {ip} (non-UTF-8, {len(message)} bytes)")
continue
else:
text = message
text = text.strip()
if not text:
continue
print(f"[WS] recv {ip}: {text}")
if not sender:
continue
try:
parsed = json.loads(text)
except json.JSONDecodeError:
try:
await sender.send(text)
except Exception:
pass
continue
if isinstance(parsed, dict):
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
try:
await sender.send(payload, addr=addr)
except Exception as e:
print(f"[WS] forward to bridge failed: {e}")
else:
try:
await sender.send(text)
except Exception:
pass
async def _driver_connection_loop(ip: str) -> None:
global _settings
if _settings is None:
return
port = int(_settings.get("wifi_driver_ws_port", 80))
path = str(_settings.get("wifi_driver_ws_path", "/ws"))
if not path.startswith("/"):
path = "/" + path
uri = f"ws://{ip}:{port}{path}"
retry_interval_s = 2.0
retry_window_s = 30.0
deadline = asyncio.get_running_loop().time() + retry_window_s
try:
while True:
now = asyncio.get_running_loop().time()
if now >= deadline:
print(
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s; "
"stopping retries until next hello"
)
break
try:
print(f"[WS] connecting to {uri!r}")
async with websockets.connect(
uri,
ping_interval=20,
ping_timeout=15,
open_timeout=30,
) as ws:
_register_ws(ip, ws)
try:
await _recv_forward_loop(ip, ws)
finally:
unregister_tcp_writer(ip, ws)
except asyncio.CancelledError:
raise
except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None)
except Exception as e:
if _benign_ws_connect_failure(e):
n = _unreachable_counts.get(ip, 0) + 1
_unreachable_counts[ip] = n
if n == 1 or (n % 30) == 0:
print(f"[WS] driver {ip} unreachable, retry in 2s: {e} (x{n})")
else:
print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None)
await asyncio.sleep(retry_interval_s)
except asyncio.CancelledError:
unregister_tcp_writer(ip, None)
raise
finally:
_tasks.pop(ip, None)
def ensure_driver_connection(peer_ip: str) -> None:
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return
t = _tasks.get(key)
if t is not None and not t.done():
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
_tasks[key] = loop.create_task(_driver_connection_loop(key))
def cancel_all_driver_tasks() -> None:
"""Signal shutdown: cancel outbound driver connection tasks."""
for _ip, t in list(_tasks.items()):
if not t.done():
t.cancel()
_tasks.clear()
for ip in list(_connections.keys()):
_schedule_status_broadcast(ip, False)
_connections.clear()
_send_locks.clear()
_unreachable_counts.clear()

View File

@@ -48,11 +48,15 @@ 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: newline-delimited JSON over TCP (see led-driver WiFi transport)
if 'tcp_enabled' not in self:
self['tcp_enabled'] = True
if 'tcp_port' not in self:
self['tcp_port'] = 8765
# 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'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
def save(self):
try:

View File

@@ -1,4 +1,4 @@
"""Push Wi-Fi TCP connect/disconnect updates to browser WebSocket clients."""
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
import json
import threading
@@ -20,7 +20,7 @@ async def unregister_device_status_ws(ws: Any) -> None:
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
from models.tcp_clients import normalize_tcp_peer_ip
from models.wifi_ws_clients import normalize_tcp_peer_ip
ip = normalize_tcp_peer_ip(ip)
if not ip:
@@ -42,7 +42,7 @@ async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
from models import tcp_clients as tcp
from models import wifi_ws_clients as tcp
ips = tcp.list_connected_ips()
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})

View File

@@ -1,10 +1,10 @@
"""Deliver driver JSON messages over serial (ESP-NOW) and/or TCP (Wi-Fi clients)."""
"""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.tcp_clients import send_json_line_to_ip
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"
@@ -20,7 +20,7 @@ def _split_serial_envelope(inner_json_str, peer_hex_list):
def _wifi_message_for_device(msg, device_name):
"""
For Wi-Fi TCP fanout, narrow a v1 select map to a single 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:
@@ -40,6 +40,33 @@ def _wifi_message_for_device(msg, 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,
@@ -50,8 +77,8 @@ async def deliver_preset_broadcast_then_per_device(
):
"""
Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each
Wi-Fi driver over TCP. If default_id is set, send a per-target default message
(unicast serial or TCP) with targets=[device name] for each registry entry.
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
@@ -72,17 +99,22 @@ async def deliver_preset_broadcast_then_per_device(
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)]
for ip in wifi_ips:
if ip:
tasks.append(send_json_line_to_ip(ip, msg))
results = await asyncio.gather(*tasks, return_exceptions=True)
if results and results[0] is True:
deliveries += 1
for r in results[1:]:
if r is True:
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:
@@ -98,7 +130,7 @@ async def deliver_preset_broadcast_then_per_device(
if await send_json_line_to_ip(ip, out):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default TCP failed: {e!r}")
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
else:
try:
await sender.send(out, addr=mac)
@@ -112,10 +144,10 @@ 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 TCP clients.
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 TCP in parallel. Multiple ESP-NOW peers are sent in **one** serial
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.