"""Async UDP transport wrapper for sending LED packets to devices. Provides a single shared socket that can sendto() multiple device addresses. One socket handles all devices — UDP is connectionless, no socket pool needed. Usage: sender = UDPSender() await sender.start() # Open socket at app startup sender.send(payload, ip, port) # Fire-and-forget from async context await sender.stop() # Close socket at app shutdown """ import asyncio class _NullProtocol(asyncio.DatagramProtocol): """Minimal datagram protocol — we only send, never receive on this socket.""" def error_received(self, exc: Exception) -> None: # UDP send errors are non-fatal for LED strip delivery. # Network hiccups should not crash the show. pass class UDPSender: """Single shared UDP socket for sending LED frames and animation commands. Thread safety: sendto() is NOT thread-safe across OS thread boundaries. Always call send() from within the asyncio event loop. If bridging from sync code, use loop.call_soon_threadsafe(). """ def __init__(self) -> None: self._transport: asyncio.DatagramTransport | None = None async def start(self) -> None: """Open the UDP socket. Call once during FastAPI lifespan startup.""" loop = asyncio.get_running_loop() self._transport, _ = await loop.create_datagram_endpoint( _NullProtocol, local_addr=("0.0.0.0", 0), # OS assigns ephemeral source port ) def send(self, payload: bytes, ip: str, port: int) -> None: """Send payload to (ip, port). Fire-and-forget — no acknowledgement. Args: payload: Raw bytes to send (DRGB/DRGBW/DNRGB/animation command). ip: Destination IP address string. port: Destination UDP port (WLED default: 21324). """ if self._transport: self._transport.sendto(payload, (ip, port)) async def stop(self) -> None: """Close the UDP socket. Call during FastAPI lifespan shutdown.""" if self._transport: self._transport.close() self._transport = None @property def is_running(self) -> bool: """True if the socket is open and ready to send.""" return self._transport is not None