guest@6admin.io:~$

FluoLight: rewriting an inherited Arduino firmware into something defensible

Second episode of the FluoBoard series, after the autopsy of the original board . I promised a follow-up there and here it is, though it did not happen in one night. From 2021 I went at the most glaring gap first: splitting the monolith into several .ino files by responsibility, because a single sketch does not stay maintainable, fixing bugs along the way and adding the #defines that were missing. Then came a hand-written event loop, my first way of pacing actions over time, before I reached for the TimedAction library to do it properly. The bulk of the work was done in 2021-2022, with final touches coming later [edit: 2025], all of it followable commit by commit. Same board family, same /lights/<mac> protocol whose answer fits in a single digit between <...>. What changes is not the feature list, it is that the loop never stops to wait.

The one rule: never block

The old code’s core sin was delay(show_delay * 1000) in every branch. The rewrite’s core promise is that nothing ever sleeps when something could happen. The entire main loop:

void loop() {
  ethLinkStatus.check();
  if (isEthLinkActive) {
    httpRequest.check();
    httpProcess.check();
  }
  dispatchEvents.check();
  animTick();
  wdt_reset();
}

Five .check() calls on TimedAction timers: link status every 500 ms, HTTP request every 3 s, HTTP read every 600 ms, event dispatch every 50 ms, animation tick every pass. The board polls as often as configured and stays responsive in between: the button, the animations, the watchdog all keep breathing. delay() still exists in the codebase, but only as MAIN_LOOP_INTERVAL, defaulting to zero.

A parser that survives TCP fragmentation

The old readPage() returned a pointer to a local buffer and fell off its end on timeout. The new http.ino keeps parser state as file-scope statics so a packet boundary in the middle of <6 does not lose the digit:

} else if (httpTokenPos < sizeof(httpToken) - 1) {
  httpToken[httpTokenPos++] = c;
} else {
  httpCapturing = false;   // token overflow: abandon current capture
}

Overflow is abandoned, not truncated, so a hostile or glitchy payload cannot loop forever. And the accepted token is strictly one digit (httpTokenPos == 1 && isDigit(httpToken[0])): the protocol says <N>, anything else is noise and gets ignored instead of toInt()-ed into existence.

Configuration as compile-time profiles

Everything I used to hardcode in source now lives in config.h and every option documents its flash price: VERBOSE 0-3 (0 hardened production to 3 dev-only, ~+11% flash at level 3), MAC from EEPROM or manual, DHCP or static, proxy or direct, external link check on or off. Two safety nets with an honest contract in the comment: at least one of WATCHDOG or RESET_ON_FAIL must be enabled for unattended production use; the watchdog covers CPU freeze, RESET_ON_FAIL covers network error loops, enabling both is safe.

This is the answer to the old code’s “config section for non-technical people”: a config section where every switch has a documented cost and a documented failure mode and where shipping a headless board with zero safety nets is a compile-time decision you have to read to make.

Comments that no longer lie

The old file had twelve color constants, six commented // red, blues included. The new code puts the truth in exactly one place, next to the one line that must not be touched:

// Color order: the strip is physically wired GRB (not RGB).
// NEO_RGB tells the driver to send bytes in G-R-B order, which matches the hardware.
// All Color() / setPixelColor() calls use Color(G, R, B) convention - do NOT change this.

The GRB war story deserves its own paragraph and it gets one in the next episode (the wiring bit me again in a different way). The event state machine (events.ino) opens with a doc block explaining what each event does and pointing to the non-blocking animation state machine in neopixel.ino, so the next reader gets the map before the code.

Logging with a budget

The old sketch had 65 Serial.print calls, one per received character. The new one routes everything through a serialMessage(level, tag, msg) with F() flash strings, gated by the VERBOSE profile: production at level 1 logs errors only and pays ~1% flash for it; a connect failure logs KO only after the third consecutive miss (HTTP_FAIL_THRESHOLD). Debug output exists, but you must opt in at compile time, not ship it by accident.

The scoreboard

Origin (2019)FluoLight (2021-2022)
main sketch475 lines119 lines
blocking delays20 s per state0 (config MAIN_LOOP_INTERVAL=0)
parserdangling pointer, UB on timeoutstatic state, overflow guard, strict digit
confighardcoded IP/MACcompile-time profiles, documented costs
logging65 prints, blocks on seriallevel-gated, thresholded, flash-string

What FluoLight still did not have at that point: continuous integration and the Python server that turns it into a physical CI status light. That is episode three .