feat(03-03): implement UDP simulator with packet logging — GREEN
- SimulatorProtocol parses DRGB, DRGBW, DNRGB, ANIM_CMD packets - PacketLog dataclass stores led_count, start_index, decoded cmd - run_simulator() async factory for test and standalone use - Runnable as __main__ standalone server on WLED_PORT - All 7 e2e tests pass
This commit is contained in:
143
lightsync/protocol/simulator.py
Normal file
143
lightsync/protocol/simulator.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Software UDP simulator — receives and logs WLED-compatible packets.
|
||||||
|
|
||||||
|
Used for hardware-free protocol testing. Listens on a UDP port and parses
|
||||||
|
all incoming packets (DRGB, DRGBW, DNRGB, animation commands), logging
|
||||||
|
them for test assertions.
|
||||||
|
|
||||||
|
Usage as standalone server:
|
||||||
|
python -m lightsync.protocol.simulator
|
||||||
|
|
||||||
|
Usage in tests:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
SimulatorProtocol, local_addr=("127.0.0.1", 0)
|
||||||
|
)
|
||||||
|
port = transport.get_extra_info("sockname")[1]
|
||||||
|
# ... send packets to 127.0.0.1:port ...
|
||||||
|
assert protocol.packets[0].protocol == "DRGB"
|
||||||
|
transport.close()
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from lightsync.protocol.drgb import PROTOCOL_DRGB, PROTOCOL_DRGBW, PROTOCOL_DNRGB, WLED_PORT
|
||||||
|
from lightsync.protocol.animation_cmd import ANIM_CMD_MARKER, decode_animation_cmd
|
||||||
|
|
||||||
|
PROTOCOL_NAMES = {
|
||||||
|
PROTOCOL_DRGB: "DRGB",
|
||||||
|
PROTOCOL_DRGBW: "DRGBW",
|
||||||
|
PROTOCOL_DNRGB: "DNRGB",
|
||||||
|
ANIM_CMD_MARKER: "ANIM_CMD",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PacketLog:
|
||||||
|
"""Parsed representation of a received UDP packet."""
|
||||||
|
protocol: str
|
||||||
|
addr: tuple
|
||||||
|
led_count: int = 0
|
||||||
|
start_index: int = 0
|
||||||
|
raw: bytes = b""
|
||||||
|
decoded: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorProtocol(asyncio.DatagramProtocol):
|
||||||
|
"""Async UDP protocol that parses and logs incoming WLED-compatible packets.
|
||||||
|
|
||||||
|
Supports all four packet types:
|
||||||
|
- DRGB (0x02): RGB pixel data
|
||||||
|
- DRGBW (0x03): RGBW pixel data (SK6812)
|
||||||
|
- DNRGB (0x04): Multi-packet RGB with start index
|
||||||
|
- ANIM_CMD (0xAC): LightSync animation command
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.packets: list[PacketLog] = []
|
||||||
|
self.transport: asyncio.DatagramTransport | None = None
|
||||||
|
|
||||||
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
||||||
|
self.transport = transport
|
||||||
|
|
||||||
|
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
||||||
|
if len(data) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
proto_type = data[0]
|
||||||
|
name = PROTOCOL_NAMES.get(proto_type, f"UNKNOWN(0x{proto_type:02X})")
|
||||||
|
|
||||||
|
log = PacketLog(protocol=name, addr=addr, raw=data)
|
||||||
|
|
||||||
|
if proto_type == PROTOCOL_DRGB:
|
||||||
|
# Format: [0x02][timeout][R][G][B]... — 3 bytes per LED after 2-byte header
|
||||||
|
log.led_count = (len(data) - 2) // 3
|
||||||
|
|
||||||
|
elif proto_type == PROTOCOL_DRGBW:
|
||||||
|
# Format: [0x03][timeout][R][G][B][W]... — 4 bytes per LED after 2-byte header
|
||||||
|
log.led_count = (len(data) - 2) // 4
|
||||||
|
|
||||||
|
elif proto_type == PROTOCOL_DNRGB:
|
||||||
|
# Format: [0x04][timeout][start_hi][start_lo][R][G][B]...
|
||||||
|
# Start index is big-endian uint16 at bytes 2-3; pixels start at byte 4
|
||||||
|
if len(data) >= 4:
|
||||||
|
log.start_index = struct.unpack_from(">H", data, 2)[0]
|
||||||
|
log.led_count = (len(data) - 4) // 3
|
||||||
|
|
||||||
|
elif proto_type == ANIM_CMD_MARKER:
|
||||||
|
try:
|
||||||
|
log.decoded = decode_animation_cmd(data)
|
||||||
|
except Exception:
|
||||||
|
log.decoded = None
|
||||||
|
|
||||||
|
self.packets.append(log)
|
||||||
|
|
||||||
|
# Human-readable log for standalone mode
|
||||||
|
details = f"{log.led_count} LEDs" if log.led_count else ""
|
||||||
|
if proto_type == PROTOCOL_DNRGB:
|
||||||
|
details += f" start={log.start_index}"
|
||||||
|
if log.decoded:
|
||||||
|
details += f" cmd={log.decoded}"
|
||||||
|
print(f"[SIM] {name} from {addr}: {details or len(data)} bytes")
|
||||||
|
|
||||||
|
def error_received(self, exc: Exception) -> None:
|
||||||
|
print(f"[SIM] Error: {exc}")
|
||||||
|
|
||||||
|
def connection_lost(self, exc: Exception | None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def run_simulator(
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = WLED_PORT,
|
||||||
|
) -> tuple[asyncio.DatagramTransport, SimulatorProtocol]:
|
||||||
|
"""Start the UDP simulator and return (transport, protocol).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Bind address. Use "0.0.0.0" for all interfaces or "127.0.0.1" for loopback.
|
||||||
|
port: UDP port to listen on. Defaults to WLED_PORT (21324).
|
||||||
|
Pass 0 to let the OS assign a free port (useful in tests).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (transport, protocol). Call transport.close() to stop.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
SimulatorProtocol,
|
||||||
|
local_addr=(host, port),
|
||||||
|
)
|
||||||
|
actual_port = transport.get_extra_info("sockname")[1]
|
||||||
|
print(f"[SIM] Listening on {host}:{actual_port}")
|
||||||
|
return transport, protocol
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
async def _main() -> None:
|
||||||
|
transport, protocol = await run_simulator()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(float("inf"))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
asyncio.run(_main())
|
||||||
Reference in New Issue
Block a user