How OXLEDManager Works

A walkthrough of the design and internals

What this page is

The API reference tells you what each method does and what each state looks like. This page explains how the class is built — the status→style indirection, the float-free animation engine, the change-only LED writes, and how it stays host-testable. Read it before adding a state, changing a pattern, or supporting a different LED.

Wiring diagram

A single WS2812 (NeoPixel) addressable RGB LED on one GPIO. Wire it to the ESP32-S3 Super Mini as below — begin() defaults the data pin to GPIO4. A ~330 Ω series resistor sits between GPIO4 and DIN; besides that, only power and ground are needed.

WS2812 NeoPixel RGB LED DIN VDD GND ESP32-S3 DevKitM-1 GPIO4 5V / 3V3 GND 330 Ω
data (DIN) 330 Ω series resistor power ground

WS2812 → ESP32-S3 Super Mini (default begin() pin), with a 330 Ω series resistor on the data line

WS2812ESP32-S3 Super MiniSignal
DINGPIO4 (via 330 Ω)Serial data in; level shift when VDD is 5 V because ESP32-S3 GPIOs are 3.3 V only.
VDD5V or 3V3WS2812 prefers 5 V; a single LED usually works at 3.3 V
GNDGNDGround (common with the board)

The ~330 Ω resistor in series with the data line damps reflections and limits current into the first pixel's input — a recommended practice that protects DIN and improves signal integrity (values of 220–470 Ω are all fine). For a chain of LEDs, the first pixel's DOUT feeds the next pixel's DIN; this manager drives a single status pixel.

Three layers: status → style → color

The design separates what the app is doing from what the LED looks like, with a clean indirection in the middle:

SystemStatus   (Recording, ServerConnected, SdError, ...)   // app meaning
     │  styleFor(status)
     ▼
LedStyle       { Color, Pattern }                            // visual intent
     │  renderStyle(style, startMs, nowMs)
     ▼
Color          { r, g, b } shown right now                   // concrete output
     │  ILedDevice::show(color)
     ▼
WS2812 (or a fake in tests)

Callers only ever deal with the top layer — setStatus(...). The middle layer, styleFor(), is a single switch that is the one source of truth for every state's color and pattern; the doc tables are generated from the same mapping. The bottom layer turns a style into the exact color for the current instant. Keeping these separate means adding a state touches only the enum and the switch, and changing how a pattern looks touches only the renderer.

The animation engine is float-free and stateless

renderStyle(style, startMs, nowMs) is a pure function: given a style and two timestamps, it returns the color to show — no instance state, no accumulated phase. That makes it trivially testable (feed it a time, check the color) and immune to drift.

Blinks are a modulo on elapsed time; the pulse uses an integer triangle wave scaled into the color, so there is no floating-point math on the hot path:

// Blink: on for the first half of each period, off for the second.
return (elapsed % kBlinkPeriod) < (kBlinkPeriod / 2) ? style.color : Colors::Off;

// Pulse: triangle 0..255..0 over the period, used to scale brightness.
uint32_t lvl = triangle(elapsed, kPulsePeriod);   // 0..255
return scale(style.color, lvl);                    // integer r*lvl/255, etc.

DoubleBlink (the SD-fault heartbeat) is just two short "on" windows near the start of a longer cycle — again pure time arithmetic. The period constants live in one place at the top of the .cpp, so retiming a pattern is a one-line change.

The LED is only written when the color changes

animate() renders the current color and compares it to the last one pushed; it calls ILedDevice::show() only on a difference:

Color c = renderStyle(style_, styleStartMs_, nowMs);
if (!haveLast_ || c != lastColor_) {
  device_->show(c);
  lastColor_ = c;
  haveLast_ = true;
  return true;   // output changed
}
return false;     // nothing to do

This matters because neopixelWrite() bit-bangs the WS2812 protocol with interrupts disabled for ~30 µs per LED. Pushing it every loop iteration — even for a Solid style that never changes — would waste cycles and add jitter. Writing only on change means a solid state costs one write total, and an animated state writes a handful of times per second (whenever the rendered color actually steps).

Two small pieces of state support this: lastColor_ (what's on the LED) and styleStartMs_ (when the active style began, so the animation phase is measured from the moment of the change). Setting a new status clears haveLast_, which forces the next animate() to re-anchor the clock and push immediately.

setStatus() is cheap to spam

The intended usage is to call setStatus() freely — even every loop, from a deriveStatus(flags...) helper. So it early-outs when the status is unchanged:

if (status == status_ && haveLast_) return;  // no change, keep animating

Without this guard, re-setting the same status would reset the animation clock every loop and freeze blinks/pulses at their first frame. With it, the animation runs smoothly and only a genuine state change restarts it.

Device vs. host build

The WS2812 backend lives under src/led/, which the native test environment excludes:

[env:native]
build_src_filter = +<*> -<main.cpp> -<backends/> -<crypto/> -<audio/> -<led/>

Same pattern as the other managers: begin() lives in the portable core and obtains its device from a factory hook. On-device, OXLEDManagerDevice.cpp registers a factory that constructs a Ws2812Device on the requested pin; on the host no factory exists, so a test injects a FakeLedDevice that just records the last color. The animation math, the status map, and the change-only logic are all exercised on the host with no LED attached — and a test confirms begin() fails cleanly when neither a device nor a factory is present.

One portability note: LedStyle has explicit constructors rather than relying on aggregate brace-init, because the device toolchain compiles to an older C++ standard than the native test env — without the constructors, {Colors::Blue, Pattern::Blink} fails to compile on-device.

Designing the state taxonomy

The states are deliberately split finer than "connected / recording." The goal is that the LED answers operational questions without a serial console: is it on Wi-Fi but not reaching the server? recording locally or streaming? a transient fault or a hard one? So connectivity is three states (connecting → on-Wi-Fi-no-server → server-connected), recording is three (local / Wi-Fi-but-no-server / streaming), and faults are split into a recoverable-looking SD heartbeat vs a generic error blink. Colors are grouped by domain (blue = Bluetooth, yellow = Wi-Fi, green = good/operational, purple = recording, cyan = transfer, orange = power, red = fault) so even an unfamiliar observer can reason about them.

Where it fits

OXLEDManager is the project's status surface. Subsystems call setStatus() as they change state — OXAudioManager starting/stopping capture, the Wi-Fi/Bluetooth stacks connecting, OXFileManager reporting an SD fault — and a single animate(millis()) in loop() turns that into light. Because the LED is driven behind ILedDevice, swapping the WS2812 for a different indicator (or a strip) touches only the device, not any call site.

File map

FileRole
include/OXLEDManager.hPublic API, Color, Pattern, LedStyle, SystemStatus, ILedDevice.
src/OXLEDManager.cppPortable core: styleFor() map, renderStyle() engine, begin()/setStatus()/animate(). No LED headers.
include/led/Ws2812Device.h · src/led/Ws2812Device.cppWS2812 backend via neopixelWrite() (device only).
src/led/OXLEDManagerDevice.cppRegisters the default-device factory at startup (device only).
test/test_oxledmanager/FakeLedDevice.hRecords the last color shown, for host tests.
test/test_oxledmanager/test_oxledmanager.cppUnity suite: status map, render envelopes, change-only writes, lifecycle.