Skip to content

← Back to list

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.

3 min read

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.

Binary frame layout: SOF (0xA5), VERSION, TYPE, big-endian LEN, PAYLOAD, and CRC8.

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.

Before/after comparison: bytes per frame and ceiling FPS — ASCII hex (2691 B, ~9 FPS) vs binary (903 B, ~55 FPS).

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 DecodeError hierarchy has four subclasses: VersionMismatch, CRCMismatch, LengthError, UnknownFrameType. It matters because the host responds differently to each. A CRCMismatch retransmits; a VersionMismatch aborts and renegotiates (the firmware is running an old build, say); a LengthError does not retransmit — the frame was malformed. Returning False or None would 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.