feat(03-01): UDP sender and device stub completion
- UDPSender: single shared asyncio DatagramTransport, start/stop lifecycle, fire-and-forget send() - _NullProtocol: swallows OS send errors (UDP delivery is best-effort for LEDs) - SK6812Device.encode_animation_cmd: replaces b'' stub with real 16-byte 0xAC packet - WS2801Device.encode_animation_cmd: replaces b'' stub with real 16-byte 0xAC packet - Both devices delegate to encode_animation_cmd from lightsync.protocol.animation_cmd
This commit is contained in:
63
lightsync/protocol/udp_sender.py
Normal file
63
lightsync/protocol/udp_sender.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user