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

300
tests/device_ws_cycle.py Normal file
View File

@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Discover a WiFi LED driver via UDP hello, then drive it over WebSocket.
1. Listens on UDP (default port 8766) for the same JSON line the firmware sends
(``v``, ``device_name``, ``mac``, ``type``: ``led``).
2. Opens ``ws://<device-ip>:<port>/ws``.
3. Pushes a few test presets (``v``: ``"1"``) and cycles ``select`` for the
reported ``device_name``.
The firmware sends UDP hello about one second **after** HTTP is listening, so
this script retries the WebSocket handshake by default.
The device ``settings.json`` ``name`` must match ``device_name`` in the hello
(and in each ``select`` map).
Examples::
pipenv install --dev
pipenv run python tests/device_ws_cycle.py
pipenv run python tests/device_ws_cycle.py --timeout 60 --cycle-s 4
# Skip UDP; connect directly (set ``--device-name`` to the device's ``name``)::
pipenv run python tests/device_ws_cycle.py --host 192.168.1.42 --device-name a
"""
from __future__ import annotations
import argparse
import asyncio
import json
import socket
import sys
def _parse_hello_line(data: bytes) -> tuple[dict | None, bytes]:
line = data.split(b"\n", 1)[0].strip()
if not line:
return None, line
try:
obj = json.loads(line.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return None, line
if not isinstance(obj, dict):
return None, line
return obj, line
def wait_for_udp_hello(
bind: str,
port: int,
timeout_s: float,
echo: bool,
) -> tuple[str, str, dict]:
"""Block until a valid hello arrives. Returns (device_ip, device_name, hello_dict)."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except OSError:
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except OSError:
pass
sock.bind((bind, port))
sock.settimeout(timeout_s)
print(
f"UDP listening on {bind}:{port} (timeout {timeout_s}s) — "
"power the device or wait for hello…",
)
while True:
try:
data, addr = sock.recvfrom(2048)
except socket.timeout as e:
raise SystemExit(f"No UDP hello before timeout: {e}") from e
peer_ip = addr[0]
parsed, raw_line = _parse_hello_line(data)
if parsed is None:
print(f"Ignored datagram from {peer_ip!r}: {raw_line!r}")
continue
if str(parsed.get("v") or "") != "1":
print(f"Ignored v={parsed.get('v')!r} from {peer_ip!r}")
continue
dev_type = parsed.get("type") or parsed.get("device_type")
if dev_type is not None and dev_type != "led":
print(f"Ignored type={dev_type!r} from {peer_ip!r}")
continue
name = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac")
if not name or not mac:
print(
f"Ignored hello without device_name/mac from {peer_ip!r}: {parsed!r}",
)
continue
print(
f"Heard hello: ip={peer_ip!r} device_name={name!r} mac={mac!r}",
)
if echo:
try:
sock.sendto(data, addr)
except OSError as e:
print(f"UDP echo to {addr} failed: {e!r}")
return peer_ip, name, parsed
finally:
try:
sock.close()
except OSError:
pass
PRESETS = {
"_test_on": {"p": "on", "c": [(0, 80, 200)]},
"_test_blink": {"p": "blink", "d": 120, "b": 200, "c": [(255, 40, 0), (0, 40, 255)]},
"_test_rainbow": {"p": "rainbow", "d": 12, "n1": 2, "a": True},
}
PRESET_ORDER = ["_test_on", "_test_blink", "_test_rainbow"]
async def cycle_presets(
host: str,
device_name: str,
ws_port: int,
ws_path: str,
cycle_s: float,
passes: int,
*,
ws_open_timeout_s: float,
ws_connect_retries: int,
ws_connect_retry_delay_s: float,
) -> None:
try:
import websockets
except ImportError as e:
raise SystemExit(
"Install websockets: pipenv install websockets (or: pip install websockets)"
) from e
path = ws_path if ws_path.startswith("/") else "/" + ws_path
uri = f"ws://{host}:{ws_port}{path}"
print(f"WebSocket connect {uri!r}")
n = max(1, ws_connect_retries)
last_err: BaseException | None = None
for attempt in range(n):
try:
async with websockets.connect(
uri,
open_timeout=ws_open_timeout_s,
) as ws:
print("Connected.")
push = json.dumps({"v": "1", "presets": PRESETS})
await ws.send(push)
print(f"Sent presets: {list(PRESETS.keys())}")
await asyncio.sleep(0.25)
for p in range(passes):
print(f"--- pass {p + 1}/{passes} ---")
for pname in PRESET_ORDER:
sel = json.dumps({"v": "1", "select": {device_name: [pname]}})
await ws.send(sel)
print(f" select {pname!r}")
await asyncio.sleep(cycle_s)
print("Done.")
return
except (TimeoutError, OSError, ConnectionError) as e:
last_err = e
if attempt + 1 < n:
print(
f" connect failed ({e!r}), retry {attempt + 2}/{n} in "
f"{ws_connect_retry_delay_s}s …",
)
await asyncio.sleep(ws_connect_retry_delay_s)
raise TimeoutError(
f"WebSocket handshake failed after {n} attempts: {last_err!r}",
) from last_err
def main() -> int:
parser = argparse.ArgumentParser(
description="UDP hello discovery + WebSocket preset cycle (led-driver)",
)
parser.add_argument(
"--bind",
default="0.0.0.0",
help="UDP bind address (default 0.0.0.0)",
)
parser.add_argument(
"-p",
"--udp-port",
type=int,
default=8766,
help="UDP listen port (default 8766)",
)
parser.add_argument(
"--timeout",
type=float,
default=120.0,
help="Seconds to wait for first hello (default 120)",
)
parser.add_argument(
"--no-echo",
action="store_true",
help="Do not echo the datagram back (firmware often uses wait_reply=False)",
)
parser.add_argument(
"--host",
default="",
metavar="IP",
help="Skip UDP and use this device IP",
)
parser.add_argument(
"--device-name",
default="",
metavar="NAME",
help="Device settings name for select map (required with --host if not default)",
)
parser.add_argument(
"--ws-port",
type=int,
default=80,
help="Device WebSocket port (default 80)",
)
parser.add_argument(
"--ws-path",
default="/ws",
help="WebSocket path (default /ws)",
)
parser.add_argument(
"--cycle-s",
type=float,
default=3.0,
help="Seconds between select commands (default 3)",
)
parser.add_argument(
"--passes",
type=int,
default=2,
help="How many full cycles through all test presets (default 2)",
)
parser.add_argument(
"--ws-open-timeout",
type=float,
default=30.0,
help="Per-attempt WebSocket handshake timeout in seconds (default 30)",
)
parser.add_argument(
"--ws-retries",
type=int,
default=15,
help="WebSocket connect attempts (default 15; use with device hello after HTTP)",
)
parser.add_argument(
"--ws-retry-delay",
type=float,
default=1.0,
help="Seconds between WebSocket retries (default 1)",
)
args = parser.parse_args()
if args.host:
host = args.host.strip()
device_name = (args.device_name or "a").strip()
if not device_name:
print("--device-name is required when using a generic --host", file=sys.stderr)
return 1
print(f"Using host {host!r} device_name {device_name!r} (no UDP)")
else:
host, device_name, _hello = wait_for_udp_hello(
args.bind,
args.udp_port,
args.timeout,
echo=not args.no_echo,
)
try:
asyncio.run(
cycle_presets(
host=host,
device_name=device_name,
ws_port=args.ws_port,
ws_path=args.ws_path,
cycle_s=args.cycle_s,
passes=max(1, args.passes),
ws_open_timeout_s=args.ws_open_timeout,
ws_connect_retries=args.ws_retries,
ws_connect_retry_delay_s=args.ws_retry_delay,
)
)
except KeyboardInterrupt:
print("\nInterrupted.")
return 130
return 0
if __name__ == "__main__":
raise SystemExit(main())