- ESP32Transport.send_command() injects v:1, fire-and-forget, silent drop if not ready - 512-byte payload limit enforced with warning log - datagram_received() parses STATUS ok → connected=True, stores last_status - on_status callback support for status update notifications - connection_lost() sets connected=False, clears transport ref - create_esp32_transport() async factory sends initial STATUS ping (D-12) - All protocol behaviors match docs/protocol.md exactly (D-10, D-11, D-12, D-14) - Add pytest-asyncio to dev deps + asyncio_mode=auto for test suite
155 lines
5.5 KiB
Python
155 lines
5.5 KiB
Python
"""
|
|
UDP transport client for ESP32 LED controller.
|
|
Protocol: docs/protocol.md — v1, port 4210, fire-and-forget JSON datagrams.
|
|
|
|
Decisions applied:
|
|
D-10: asyncio DatagramProtocol, fire-and-forget sendto()
|
|
D-11: Optional periodic heartbeat (status ping), non-blocking
|
|
D-12: connected/disconnected tracked via STATUS responses
|
|
D-14: Minimal error handling (fire-and-forget transport)
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROTOCOL_VERSION = 1
|
|
MAX_PAYLOAD_BYTES = 512
|
|
|
|
|
|
class ESP32Transport(asyncio.DatagramProtocol):
|
|
"""
|
|
asyncio DatagramProtocol client for the ESP32 LED controller.
|
|
|
|
Usage:
|
|
transport = await create_esp32_transport("192.168.1.100")
|
|
transport.send_command({"zone": "wand", "animation": "chase", "params": {"speed": 0.5}})
|
|
|
|
Public interface:
|
|
send_command(cmd: dict) -> None — fire-and-forget, non-blocking
|
|
connected: bool — True after first STATUS ok response
|
|
last_status: dict | None — most recent STATUS response from ESP32
|
|
"""
|
|
|
|
def __init__(self, on_status: Callable[[dict], None] | None = None):
|
|
self._transport: asyncio.DatagramTransport | None = None
|
|
self.connected: bool = False
|
|
self.last_status: dict | None = None
|
|
self._on_status = on_status # optional callback invoked on STATUS ok
|
|
|
|
# -------------------------------------------------------------------------
|
|
# asyncio DatagramProtocol callbacks
|
|
# -------------------------------------------------------------------------
|
|
|
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
"""Called by asyncio when the UDP socket is bound to the remote address."""
|
|
self._transport = transport
|
|
logger.debug("UDP socket bound to ESP32 remote address")
|
|
|
|
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
|
"""
|
|
Called when a UDP datagram arrives from the ESP32.
|
|
|
|
Parses STATUS ok responses to update connection state (D-12).
|
|
All other messages and malformed JSON are silently ignored (D-14).
|
|
"""
|
|
try:
|
|
msg = json.loads(data.decode("utf-8"))
|
|
except Exception:
|
|
logger.debug("Received non-JSON datagram from %s — ignored", addr)
|
|
return
|
|
|
|
if msg.get("status") == "ok":
|
|
self.connected = True
|
|
self.last_status = msg
|
|
logger.debug("ESP32 STATUS ok: %s", msg)
|
|
if self._on_status is not None:
|
|
self._on_status(msg)
|
|
|
|
def error_received(self, exc: Exception) -> None:
|
|
"""
|
|
Called on non-fatal UDP errors (e.g., ICMP port unreachable).
|
|
Fire-and-forget transport — errors are logged but not re-raised.
|
|
"""
|
|
logger.debug("UDP error (non-fatal): %s", exc)
|
|
|
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
"""Called when the UDP socket is closed."""
|
|
self.connected = False
|
|
self._transport = None
|
|
logger.debug("UDP transport closed (exc=%s)", exc)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Public API
|
|
# -------------------------------------------------------------------------
|
|
|
|
def send_command(self, cmd: dict) -> None:
|
|
"""
|
|
Fire-and-forget: inject "v":1 and send cmd as a JSON UDP datagram.
|
|
|
|
Safe to call from the asyncio event loop thread at any time.
|
|
Silent drop (no exception) if:
|
|
- Transport not yet ready (pre-connection)
|
|
- Resulting payload exceeds MAX_PAYLOAD_BYTES (512)
|
|
|
|
Matches docs/protocol.md exactly — "v":1 is injected here so callers
|
|
do not need to include it.
|
|
"""
|
|
if self._transport is None:
|
|
logger.debug("send_command() called before transport ready — dropped")
|
|
return
|
|
|
|
payload = {"v": PROTOCOL_VERSION, **cmd}
|
|
data = json.dumps(payload).encode("utf-8")
|
|
|
|
if len(data) > MAX_PAYLOAD_BYTES:
|
|
logger.warning(
|
|
"Command payload %d bytes exceeds %d-byte limit — dropped",
|
|
len(data),
|
|
MAX_PAYLOAD_BYTES,
|
|
)
|
|
return
|
|
|
|
self._transport.sendto(data)
|
|
|
|
def send_status_ping(self) -> None:
|
|
"""
|
|
Send a STATUS query to the ESP32 (D-11 heartbeat).
|
|
|
|
Fire-and-forget — a STATUS ok response via datagram_received() will
|
|
update connected=True and last_status. Silent drop if not ready.
|
|
"""
|
|
self.send_command({"cmd": "status"})
|
|
|
|
|
|
async def create_esp32_transport(
|
|
host: str,
|
|
port: int = 4210,
|
|
on_status: Callable[[dict], None] | None = None,
|
|
) -> "ESP32Transport":
|
|
"""
|
|
Async factory: create and return an ESP32Transport bound to host:port.
|
|
|
|
Sends an initial STATUS ping to establish connection state (D-12).
|
|
The reply (if ESP32 is reachable) will set connected=True via
|
|
datagram_received().
|
|
|
|
Args:
|
|
host: ESP32 IP address (e.g., "192.168.1.100")
|
|
port: UDP port (default 4210 per docs/protocol.md)
|
|
on_status: optional callback invoked on every STATUS ok response
|
|
|
|
Returns:
|
|
ESP32Transport instance ready for send_command() calls
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
_, protocol = await loop.create_datagram_endpoint(
|
|
lambda: ESP32Transport(on_status=on_status),
|
|
remote_addr=(host, port),
|
|
)
|
|
# Initial status ping — non-blocking, reply updates connected state (D-12)
|
|
protocol.send_status_ping()
|
|
return protocol
|