Skip to content

← Back to list

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.

3 min read

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.

Firmware parser state machine diagram: 5 states (HUNT_SOF, READ_HEADER, READ_PAYLOAD, READ_CRC, DISPATCH).

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’t 0x01, if the type is unknown, or if LEN > 1024, send an error ACK and go back to HUNT_SOF.
  • READ_PAYLOAD: read LEN bytes. No full buffer. For a DATA frame, every three bytes go straight to setPixelColor(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_FAIL ACK and out.
  • DISPATCH: if everything checks out, leds_strip->show() (DATA only) and OK ACK.

Why streaming and not a full buffer. If I buffered the DATA payload 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 next show(). 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:

  • 0x00 OK
  • 0x01 CRC_FAIL
  • 0x02 VERSION (frame from a different wire version)
  • 0x03 UNKNOWN_TYPE (TYPE not recognized)
  • 0x04 OVERFLOW (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?