The firmware: Arduino and critical timing
An Arduino Uno has 2 KB of RAM. A 300-LED strip is 900 bytes just for pixel buffers. Every decision is a question of space.
An Arduino Uno has two kilobytes of RAM. A three-hundred LED strip painted in RGB takes nine hundred bytes just for the pixel buffer. Half the memory, just for “what do I paint”. Every firmware decision is, really, a question of space.
The context
The firmware’s role
(lumware-firmware/lumware-firmware.ino) is intentionally
narrow: receive bytes over serial, identify frames, paint
the strip with the exact timing WS281x demands, reply with
an ACK. Nothing else. No stored animations, no palettes,
no “demo mode”. All of that lives on the host.
This split is not clean for the sake of aesthetics. It’s forced: the Uno doesn’t have memory for an image atlas, it can’t read a disk, and it doesn’t have a precise enough clock to drive complex animations with reliable timing. What it does have is a fast output pin and a library (Adafruit_NeoPixel) that respects the WS281x electrical protocol. That is exactly what we use.
The decision
The firmware is a four-state machine:
HUNT_SOF → READ_HEADER → READ_PAYLOAD → READ_CRC → DISPATCH
- HUNT_SOF: drop every byte that isn’t
0xA5. When it finds one, it anchors a new frame and resets the internal state. - READ_HEADER: collect 4 bytes (
VERSION,TYPE,LEN_HI,LEN_LO). If the version isn’t0x01, if the type is unknown, or ifLEN > 1024, send an error ACK and go back to HUNT_SOF. - READ_PAYLOAD: read
LENbytes. No full buffer. For aDATAframe, every three bytes go straight tosetPixelColor(led_idx, r, g, b)and the firmware moves to the next. 900 bytes never sit in memory at once. - READ_CRC: a final byte. Compared against the running
CRC8 computed since
VERSION. If it mismatches,CRC_FAILACK and out. - DISPATCH: if everything checks out,
leds_strip->show()(DATA only) andOKACK.
Why streaming and not a full buffer. If I buffered the
DATApayload to validate the CRC before painting, I’d need up to 1024 bytes — half the Uno’s RAM, just for a frame that might be invalid. So the “price” of streaming is having to roll back the frame if the CRC fails: the pixels already written stay painted until the nextshow(). In practice it’s acceptable — CRCs rarely fail, and the worst-case loss is one frame’s content, not a crash.show()only runs if the CRC is OK, so on error the frame never reaches the strip even though the bytes landed in the internal buffer.
There are five typed ACK status codes:
0x00OK0x01CRC_FAIL0x02VERSION(frame from a different wire version)0x03UNKNOWN_TYPE(TYPE not recognized)0x04OVERFLOW(LEN > MAX_PAYLOAD)
Why five and not one? Because the cause matters:
CRC_FAIL justifies immediate retransmit; VERSION
justifies aborting and renegotiating; OVERFLOW is a host
bug to fix, not to retransmit. Distinguishing them in the
firmware lets the host decide.
What comes next
Back to the host to look at why the protocol has this shape and not JSON. Why 6 bytes of overhead, why big-endian, why CRC8 and not CRC16 or nothing. Was it a choice or an inevitable consequence?