From d14fa3ca789e2de1ca8b4b611165e3b650e88028 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 15:40:30 +0000 Subject: [PATCH] feat(07-03): add FrameAssembler to firmware/protocol.py - Add FrameAssembler class for multi-packet DNRGB reassembly - 200ms timeout discards stale partial frames - feed() returns complete frame list when all expected pixels received - reset() clears buffer manually --- firmware/protocol.py | 137 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 firmware/protocol.py diff --git a/firmware/protocol.py b/firmware/protocol.py new file mode 100644 index 0000000..938736b --- /dev/null +++ b/firmware/protocol.py @@ -0,0 +1,137 @@ +import struct + +# Protocol type bytes (first byte of UDP packet) +PROTO_DRGB = 0x02 +PROTO_DRGBW = 0x03 +PROTO_DNRGB = 0x04 +PROTO_ANIM_CMD = 0xAC + +ANIMATION_NAMES = { + 0: 'solid_color', 1: 'chase', 2: 'pulse', + 3: 'rainbow', 4: 'strobe', 5: 'color_wipe', 6: 'fire' +} + + +def classify_packet(data): + """Return packet type string from first byte, or None if unknown.""" + if not data: + return None + b = data[0] + if b == PROTO_DRGB: + return 'drgb' + elif b == PROTO_DRGBW: + return 'drgbw' + elif b == PROTO_DNRGB: + return 'dnrgb' + elif b == PROTO_ANIM_CMD: + return 'anim_cmd' + return None + + +def parse_drgb(data): + """Parse DRGB packet -> list of (R,G,B) tuples. + + Format: [0x02][timeout][R0][G0][B0]...[Rn][Gn][Bn] + """ + n = (len(data) - 2) // 3 + pixels = [] + for i in range(n): + off = 2 + i * 3 + pixels.append((data[off], data[off + 1], data[off + 2])) + return pixels + + +def parse_drgbw(data): + """Parse DRGBW packet -> list of (R,G,B,W) tuples. + + Format: [0x03][timeout][R0][G0][B0][W0]...[Rn][Gn][Bn][Wn] + """ + n = (len(data) - 2) // 4 + pixels = [] + for i in range(n): + off = 2 + i * 4 + pixels.append((data[off], data[off + 1], data[off + 2], data[off + 3])) + return pixels + + +def parse_dnrgb(data): + """Parse DNRGB packet -> (start_index, list of (R,G,B) tuples). + + Format: [0x04][timeout][start_hi][start_lo][R0][G0][B0]... + Start index is big-endian uint16. + """ + start_index = struct.unpack('>H', data[2:4])[0] + n = (len(data) - 4) // 3 + pixels = [] + for i in range(n): + off = 4 + i * 3 + pixels.append((data[off], data[off + 1], data[off + 2])) + return start_index, pixels + + +def parse_animation_cmd(data): + """Parse animation command packet -> dict with animation name and params. + + Format (16 bytes, big-endian): + Byte 0: 0xAC marker + Byte 1: version (1) + Byte 2: animation type ID (0-6) + Byte 3: flags (bit 0 = reverse) + Bytes 4-7: speed (float32 big-endian) + Bytes 8-10: primary color R, G, B + Bytes 11-13: background color R, G, B + Byte 14: white channel + Byte 15: density/size/cooling + """ + if len(data) < 16 or data[0] != PROTO_ANIM_CMD: + return None + anim_id = data[2] + flags = data[3] + speed = struct.unpack('>f', data[4:8])[0] + anim_name = ANIMATION_NAMES.get(anim_id) + if anim_name is None: + return None + return { + 'animation': anim_name, + 'speed': speed, + 'color': (data[8], data[9], data[10]), + 'bg_color': (data[11], data[12], data[13]), + 'white': data[14], + 'density': data[15], + 'reverse': bool(flags & 0x01), + } + + +class FrameAssembler: + """Reassembles multi-packet DNRGB frames. + + For 300-LED strips, frames fit in one packet so reassembly never triggers. + Implemented for completeness and future strip sizes. + """ + + def __init__(self, expected_count): + self.expected_count = expected_count + self._buf = {} # start_index -> list of (R,G,B) + self._last_ms = 0 + self._TIMEOUT_MS = 200 + + def feed(self, start_index, pixels, now_ms): + """Feed a DNRGB chunk. Returns complete frame list or None.""" + # Timeout: discard stale partial frames + if self._buf and (now_ms - self._last_ms) > self._TIMEOUT_MS: + self._buf.clear() + + self._last_ms = now_ms + + for i, px in enumerate(pixels): + self._buf[start_index + i] = px + + if len(self._buf) >= self.expected_count: + frame = [self._buf.get(i, (0, 0, 0)) for i in range(self.expected_count)] + self._buf.clear() + return frame + + return None + + def reset(self): + self._buf.clear()