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 → ESP32-S3 Super Mini (default begin() pin), with a 330 Ω series resistor on the data line
| WS2812 | ESP32-S3 Super Mini | Signal |
|---|---|---|
DIN | GPIO4 (via 330 Ω) | Serial data in; level shift when VDD is 5 V because ESP32-S3 GPIOs are 3.3 V only. |
VDD | 5V or 3V3 | WS2812 prefers 5 V; a single LED usually works at 3.3 V |
GND | GND | Ground (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
| File | Role |
|---|---|
include/OXLEDManager.h | Public API, Color, Pattern, LedStyle, SystemStatus, ILedDevice. |
src/OXLEDManager.cpp | Portable core: styleFor() map, renderStyle() engine, begin()/setStatus()/animate(). No LED headers. |
include/led/Ws2812Device.h · src/led/Ws2812Device.cpp | WS2812 backend via neopixelWrite() (device only). |
src/led/OXLEDManagerDevice.cpp | Registers the default-device factory at startup (device only). |
test/test_oxledmanager/FakeLedDevice.h | Records the last color shown, for host tests. |
test/test_oxledmanager/test_oxledmanager.cpp | Unity suite: status map, render envelopes, change-only writes, lifecycle. |