feat(02-02): implement ESP32Transport UDP client with asyncio DatagramProtocol
- 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
This commit is contained in:
154
src/led_sync/transport/udp_client.py
Normal file
154
src/led_sync/transport/udp_client.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user