Why a binary protocol (and not JSON)
The easy option was JSON. It was also the option that made 30 FPS physically impossible. A six-byte-overhead binary protocol clears the bottleneck.
The easy option was JSON. Quick to write, debuggable with
tail -f, human-readable. It was also the option that made hitting 30 FPS physically impossible.
The context
The first host shipped hex ASCII over the wire: one line
per LED, format 0xRRGGBB\n. Eight characters plus a
newline = 9 bytes per LED. For 299 LEDs: 2,691 bytes
per frame (including the trailing \n).
At a real 230,400 baud — counting 10 bits per byte once you add start and stop bits — the wire delivers about 23,040 bytes per second. A single frame needs ~117 ms just to travel. Theoretical ceiling: under 9 FPS, before counting anything in the host, anything in the Arduino, anything for CRC, anything for error recovery.
The PRD asks for sustained 30 FPS at 300+ LEDs. The gap between 9 and 30 isn’t optimizable: the protocol cannot.
The decision
A minimal binary frame, no ambition:
Offset Size Field
0 1 SOF = 0xA5
1 1 VERSION = 0x01
2 1 TYPE (DATA=0x01, INIT=0x02, PING=0x03, ACK=0x04)
3 2 LEN (big-endian, uint16)
5 LEN PAYLOAD
5+LEN 1 CRC8 (covers VERSION..PAYLOAD-1)
Six bytes of total overhead per frame, regardless of content. Four frame types:
DATA: N × 3 RGB bytes. Daily bread.INIT: 4 bytes — pin (uint16) + npixels (uint16). The host configures the strip at boot.PING: empty payload. Health check.ACK: 1 byte status. Firmware reply.
The same 299-LED frame is now 5 + 897 + 1 = 903 bytes. At 500,000 baud — the new setpoint — that’s ~50,000 bytes/second. Theoretical ceiling: ~55 FPS. Real headroom for the 30 FPS target with error handling, retransmits, and backpressure included.
Why these specific choices
Why SOF = 0xA5. The bit pattern 10100101 alternates
enough that any stuck-low or stuck-high byte is visible,
and it’s an unlikely value to appear by accident at the
start of a real RGB payload.
Why LEN big-endian. No efficiency reason; just wire
convention (“network byte order”). C firmware unpacks it
in two lines; so does Python.
Why CRC8 and not CRC16 or nothing. At 30 FPS with
short frames, a 1 / 256 chance of missing an error is
tolerable. CRC16 doubles per-frame overhead without
detecting much better at this size. No CRC at all lets
silent errors through — and with WS281x that becomes a
random LED in the middle of the strip, not a crash. Seeing
sporadic errors you can retransmit is better than detecting
nothing.
Why generators in the decoder. The host codec exposes
decode(stream) -> Iterator[Frame]. It reads bytes until
it can anchor a SOF; any other byte before that is
silently dropped (resync). Same behavior as the
firmware. If the cable picks up noise or there are orphan
bytes between concatenated frames, the codec recovers
without needing to retransmit.
Typed errors, not booleans. The
DecodeErrorhierarchy has four subclasses:VersionMismatch,CRCMismatch,LengthError,UnknownFrameType. It matters because the host responds differently to each. ACRCMismatchretransmits; aVersionMismatchaborts and renegotiates (the firmware is running an old build, say); aLengthErrordoes not retransmit — the frame was malformed. ReturningFalseorNonewould lose that distinction and hide a host bug behind a retransmit that would never succeed.
What comes next
We have a protocol that fits through the wire. Now we need
a host that can generate frames at 30 FPS, do it while the
web UI is asking for things, and not freeze the moment the
happy path (tick → encode → write → ack) skips a
millisecond. Next: the Python architecture.