From f17e77f7bb16c65313980e2ad0e0fbe9825a7ee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 21:42:29 +0000 Subject: [PATCH] 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 --- lightsync/devices/sk6812.py | 5 ++- lightsync/devices/ws2801.py | 5 ++- lightsync/protocol/udp_sender.py | 63 ++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 lightsync/protocol/udp_sender.py diff --git a/lightsync/devices/sk6812.py b/lightsync/devices/sk6812.py index f9fd5a2..da989ac 100644 --- a/lightsync/devices/sk6812.py +++ b/lightsync/devices/sk6812.py @@ -1,5 +1,6 @@ from lightsync.devices.base import BaseDevice from lightsync.models.device import DeviceConfig +from lightsync.protocol.animation_cmd import encode_animation_cmd as _encode_cmd class SK6812Device(BaseDevice): @@ -24,5 +25,5 @@ class SK6812Device(BaseDevice): return bytes(buf) def encode_animation_cmd(self, animation: str, params: dict) -> bytes: - """Stub — Phase 3 implements real animation command encoding.""" - return b"" + """Encode animation command as 16-byte 0xAC LightSync packet.""" + return _encode_cmd(animation, params) diff --git a/lightsync/devices/ws2801.py b/lightsync/devices/ws2801.py index 9f52d97..0dc3a45 100644 --- a/lightsync/devices/ws2801.py +++ b/lightsync/devices/ws2801.py @@ -1,5 +1,6 @@ from lightsync.devices.base import BaseDevice from lightsync.models.device import DeviceConfig +from lightsync.protocol.animation_cmd import encode_animation_cmd as _encode_cmd class WS2801Device(BaseDevice): @@ -23,5 +24,5 @@ class WS2801Device(BaseDevice): return bytes(buf) def encode_animation_cmd(self, animation: str, params: dict) -> bytes: - """Stub — Phase 3 implements real animation command encoding.""" - return b"" + """Encode animation command as 16-byte 0xAC LightSync packet.""" + return _encode_cmd(animation, params) diff --git a/lightsync/protocol/udp_sender.py b/lightsync/protocol/udp_sender.py new file mode 100644 index 0000000..327362c --- /dev/null +++ b/lightsync/protocol/udp_sender.py @@ -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