feat(controller): migrate wifi drivers from tcp to websocket clients
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
302
src/main.py
302
src/main.py
@@ -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 Wi‑Fi 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
281
src/models/wifi_ws_clients.py
Normal file
281
src/models/wifi_ws_clients.py
Normal 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()
|
||||
@@ -48,11 +48,15 @@ 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: 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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user