feat(controller): migrate wifi drivers from tcp to websocket clients
This commit is contained in:
300
tests/device_ws_cycle.py
Normal file
300
tests/device_ws_cycle.py
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover a Wi‑Fi 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())
|
||||
Reference in New Issue
Block a user