Files
led-sync-studio/.planning/phases/01-esp32-firmware/01-RESEARCH.md
2026-04-03 13:02:30 +02:00

33 KiB
Raw Blame History

Phase 1: ESP32 Firmware - Research

Researched: 2026-04-03 Domain: ESP32-C3 embedded firmware — dual LED strip control (WS2801 SPI + SK6812 RMT), UDP/JSON command reception, FreeRTOS animation engine Confidence: MEDIUM-HIGH (hardware-specific constraints require on-device validation for several items)


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Animations use a palette-based color system with full RGBW support. Each animation accepts a color palette (1-N colors as RGBW arrays) plus effect-specific parameters (speed, intensity, direction).
  • D-02: White channel (W) on SK6812 is auto-derived from RGB when not explicitly specified in the command. User can override W directly for warm/cool white effects.
  • D-03: 8 initial animations: Chase, Pulse, Rainbow, Strobe, Color Wash, Breathe, Sparkle, Gradient Sweep. All must work on both strip types.
  • D-04: Animation tick rate: 50fps (20ms per frame) as a FreeRTOS task. This is the animation engine update rate, not the LED refresh rate.
  • D-05: Flat JSON command structure: {"zone":"schrank"|"wand"|"all", "animation":"chase", "params":{"speed":0.5, "intensity":1.0, "colors":[[255,0,128,0]]}}. Zone field is mandatory.
  • D-06: Additional commands: {"cmd":"stop","zone":"all"}, {"cmd":"brightness","zone":"all","value":100}, {"cmd":"status"} (returns JSON via UDP response).
  • D-07: Unknown commands are ignored silently; errors logged to serial console for debugging.
  • D-08: Protocol includes a "v":1 version field in every command for future compatibility.
  • D-09: Firmware-enforced brightness cap at 40% (configurable via JSON command, max 60%). Research flagged 27A peak at full brightness — dedicated 5V supplies per strip required.
  • D-10: On WiFi disconnect: continue running the last animation. On reconnect: accept new commands immediately. No auto-off timeout.
  • D-11: Startup behavior: boot with a subtle breathing animation (low brightness) to indicate firmware is alive and WiFi is connecting.
  • D-12: PlatformIO with Arduino framework. NeoPixelBus (RMT) for SK6812, hardware SPI for WS2801.
  • D-13: ArduinoJson v7 for JSON parsing, AsyncUDP for WiFi command reception.
  • D-14: Serial debug output for development. No OTA in Phase 1 — flash via USB.

Claude's Discretion

  • FreeRTOS task priorities and stack sizes
  • Exact GPIO pin assignments (validate on hardware — ESP32-C3 SuperMini pinout constraints)
  • DMA buffer sizing for RMT to mitigate WiFi interrupt interference
  • WiFi reconnection strategy and timing

Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope.

</user_constraints>


<phase_requirements>

Phase Requirements

ID Description Research Support
FW-01 ESP32-C3 drives WS2801 (160 LEDs, SPI) and SK6812 (300 LEDs, RMT/RGBW) simultaneously NeoPixelBus library handles both; RMT is non-blocking (DMA), SPI is blocking-fast — concurrent output pattern established
FW-02 ESP32-C3 receives JSON commands over UDP and executes named animations AsyncUDP (built into arduino-esp32 core) + ArduinoJson v7 + FreeRTOS queue pattern documented
FW-03 At least 8 built-in animations (Chase, Pulse, Rainbow, Strobe, Color Wash, Breathe, Sparkle, Gradient Sweep) Animation registry pattern documented; all 8 are 1D strip effects implementable with frame buffer approach
FW-04 Each animation accepts configurable parameters (color/RGBW, speed, intensity) RGBW palette system + params struct pattern; ArduinoJson v7 extracts typed fields cleanly
FW-05 Both LED zones (Schrank/Wand) are independently controllable via JSON protocol Zone field in protocol routes to independent strip state machines; concurrent RMT+SPI output confirmed

</phase_requirements>


Summary

Phase 1 builds a complete ESP32-C3 SuperMini firmware that drives two LED strips simultaneously (WS2801 via SPI, SK6812 via RMT), receives animation commands over UDP/JSON, and executes 8 named animations autonomously. The architecture is a three-task FreeRTOS system: a WiFi/UDP receiver that copies raw packets into a queue, an animation tick task that updates two independent frame buffers at 50fps, and an LED output task that drains the buffers to hardware.

The most critical risk for this phase is the ESP32-C3 WiFi+RMT coexistence problem — a well-documented issue where WiFi interrupt activity (every ~1ms) corrupts single-wire LED timing on the single-core RISC-V chip. This must be tested and validated on day one before any animation logic is written. The mitigation strategy is: use NeoPixelBus RMT with DMA enabled and buffer sized to hold a full frame, plus esp_wifi_set_ps(WIFI_PS_NONE). A secondary risk is NeoPixelBus compatibility with Arduino ESP32 core 3.x due to legacy RMT driver conflicts — the platformio.ini must include the workaround build flag.

Primary recommendation: Validate WiFi+RMT coexistence on first flash with a static SK6812 frame + rapid UDP command bursts. If flickering occurs, confirm DMA buffer sizing and WIFI_PS_NONE before proceeding to animation code.


Standard Stack

Core

Library Version Purpose Why Standard
PlatformIO latest Build system, dependency management CLI-driven, reproducible platformio.ini builds; far superior to Arduino IDE for this project. Manages NeoPixelBus + ArduinoJson versions explicitly.
Arduino framework (espressif32) 3.x Hardware abstraction over ESP-IDF Largest ESP32 library ecosystem; AsyncUDP is stdlib in this framework. Core 3.x is ESP-IDF 5 based.
NeoPixelBus by Makuna ^2.8.0 SK6812 RMT drive + WS2801 SPI drive Handles both strip protocols in one library. RMT method uses DMA, hardware-timed, no CPU spin. RGBW (4 bytes/LED) correctly supported for SK6812.
ArduinoJson ^7.4.0 JSON deserialization of commands v7 uses heap-allocated elastic documents — no manual buffer sizing. <1ms parse time for typical payloads.
AsyncUDP (arduino-esp32 stdlib) UDP packet reception without blocking Built into arduino-esp32 core, no external dep. Receives packets via callback, ISR-safe copy pattern needed.

Supporting

Library Version Purpose When to Use
FreeRTOS (ESP-IDF stdlib) Task scheduling, inter-task queues Already available in Arduino ESP32. Use xQueueCreate/xQueueSend for UDP→animation data flow.
Arduino SPI.h (core stdlib) WS2801 SPI output (fallback) Only needed if NeoPixelBus NeoWs2801SpiMethod has issues. Prefer NeoPixelBus method.

Alternatives Considered

Instead of Could Use Tradeoff
NeoPixelBus (RMT) FastLED FastLED disables interrupts during WS2812 transfer — causes WiFi packet drops. FastLED ESP32-C3 RMT support is also less stable.
NeoPixelBus (RMT) Adafruit NeoPixel Same interrupt-disable problem as FastLED. No RGBW SK6812 4-byte mode.
ArduinoJson v7 ArduinoJson v6 v6 requires pre-sized StaticJsonDocument — fragile with variable-length color arrays. v7 elastic docs are safer.
AsyncUDP ESPAsyncWebServer ESPAsyncWebServer is archived (January 2025), has crash bugs on single-core C3.
AsyncUDP plain WiFiUDP WiFiUDP is blocking — must poll in a loop. AsyncUDP uses callback, cleaner with FreeRTOS.

Installation (platformio.ini):

[env:esp32c3_supermini]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200

build_flags =
    -DARDUINO_USB_CDC_ON_BOOT=1
    -DARDUINO_USB_MODE=1
    -DESP32_ARDUINO_NO_RGB_BUILTIN

lib_deps =
    makuna/NeoPixelBus @ ^2.8.0
    bblanchon/ArduinoJson @ ^7.4.0

The -DESP32_ARDUINO_NO_RGB_BUILTIN flag is mandatory — it prevents the Arduino core from linking neopixelWrite() (new RMT driver) alongside NeoPixelBus (legacy RMT driver), which causes a fatal startup conflict: CONFLICT! driver_ng is not allowed to be used with the legacy driver.

Note on board name: esp32-c3-devkitm-1 is the closest standard PlatformIO board for the SuperMini. The nologo_esp32c3_super_mini board variant exists in newer platform versions. Either works; esp32-c3-devkitm-1 is more widely tested.


Architecture Patterns

firmware/
├── platformio.ini
├── src/
│   ├── main.cpp              # Setup + WiFi init + task launch
│   ├── config.h              # Pin defs, constants, brightness cap
│   ├── led_driver.h/.cpp     # NeoPixelBus strip objects, show()
│   ├── animation_engine.h/.cpp  # 50fps tick, zone state machines
│   ├── animations/
│   │   ├── animation_base.h  # Abstract base: tick(t, params, buffer)
│   │   ├── chase.cpp
│   │   ├── pulse.cpp
│   │   ├── rainbow.cpp
│   │   ├── strobe.cpp
│   │   ├── color_wash.cpp
│   │   ├── breathe.cpp
│   │   ├── sparkle.cpp
│   │   └── gradient_sweep.cpp
│   ├── protocol.h/.cpp       # JSON parse, command struct, response
│   └── wifi_server.h/.cpp    # AsyncUDP listener, queue send
└── test/                     # (optional: Unity unit tests for animation math)

Pattern 1: Three-Task FreeRTOS Architecture

What: Split firmware into three FreeRTOS tasks with explicit priority ordering and queue-based communication.

When to use: Any firmware that must receive network data while simultaneously outputting to hardware at a fixed tick rate.

// Source: ESP32 FreeRTOS documentation + architecture research

// Task priorities (higher number = higher priority on single core)
#define PRIORITY_LED_OUTPUT   3   // Highest: LED timing is critical
#define PRIORITY_ANIM_TICK    2   // Medium: 50fps tick
#define PRIORITY_WIFI_RECV    1   // Lowest: network I/O can wait

// Stack sizes (in words on ESP32 = bytes/4 on most compilers, but
// xTaskCreate uses words on ESP-IDF / bytes on Arduino abstraction)
// Use 4096 bytes for animation task (JSON heap alloc needs headroom)
// Use 8192 bytes for WiFi task (TLS/TCP stack even for UDP)
#define STACK_WIFI   8192
#define STACK_ANIM   4096
#define STACK_LED    2048

// Command queue: UDP → Animation Engine
QueueHandle_t commandQueue;

void setup() {
    commandQueue = xQueueCreate(8, sizeof(LedCommand));
    xTaskCreate(wifiTask,     "wifi",  STACK_WIFI, NULL, PRIORITY_WIFI_RECV, NULL);
    xTaskCreate(animTickTask, "anim",  STACK_ANIM, NULL, PRIORITY_ANIM_TICK, NULL);
    xTaskCreate(ledOutputTask,"led",   STACK_LED,  NULL, PRIORITY_LED_OUTPUT, NULL);
}

Why LED task has highest priority: On single-core C3, if the LED output task wakes for a show() call but gets preempted by WiFi, the timing gap causes LED corruption. Keeping LED output highest ensures it completes without interruption.

Pattern 2: AsyncUDP → FreeRTOS Queue (Never Parse in Callback)

What: AsyncUDP callback copies raw packet bytes into a FreeRTOS queue. A separate task drains the queue and parses JSON.

When to use: Always — JSON parsing (heap allocation, string iteration) inside a network callback causes watchdog resets.

// Source: Arduino ESP32 AsyncUDP example + ArduinoJson FreeRTOS docs

AsyncUDP udp;

struct RawPacket {
    uint8_t data[512];
    size_t len;
};

QueueHandle_t rawQueue;

void setupUDP() {
    rawQueue = xQueueCreate(4, sizeof(RawPacket));
    if (udp.listen(UDP_PORT)) {
        udp.onPacket([](AsyncUDPPacket packet) {
            RawPacket raw;
            raw.len = min(packet.length(), (size_t)512);
            memcpy(raw.data, packet.data(), raw.len);
            // ISR-safe send (timeout=0, don't block in callback)
            xQueueSendFromISR(rawQueue, &raw, NULL);
        });
    }
}

void parseTask(void* pvParams) {
    RawPacket raw;
    while (true) {
        if (xQueueReceive(rawQueue, &raw, portMAX_DELAY)) {
            JsonDocument doc;
            DeserializationError err = deserializeJson(doc, raw.data, raw.len);
            if (err) { Serial.println(err.c_str()); continue; }
            // ... extract command and send to animation engine
        }
    }
}

Pattern 3: Animation Registry with Function Pointers

What: Register animations by string name in a std::map (or flat array) at boot. The command handler looks up the name and sets a function pointer on the zone's state.

When to use: Any firmware with multiple named animation modes.

// Source: firmware architecture decision

struct AnimParams {
    uint8_t colors[8][4];  // up to 8 RGBW colors
    uint8_t colorCount;
    float speed;
    float intensity;
    uint8_t direction;
};

typedef void (*AnimTickFn)(uint32_t tick, const AnimParams& p,
                            uint8_t* frameBuffer, uint16_t ledCount);

struct AnimEntry { const char* name; AnimTickFn fn; };

const AnimEntry ANIM_REGISTRY[] = {
    {"chase",          animChase},
    {"pulse",          animPulse},
    {"rainbow",        animRainbow},
    {"strobe",         animStrobe},
    {"color_wash",     animColorWash},
    {"breathe",        animBreathe},
    {"sparkle",        animSparkle},
    {"gradient_sweep", animGradientSweep},
};

Pattern 4: Concurrent LED Output (RMT + SPI)

What: Start RMT transfer (non-blocking DMA), then write SPI (blocking, fast), return. Both strips update in roughly the same time window.

When to use: Every animation tick — this is the LED output pattern for the two-strip architecture.

// Source: ESP32-C3 RMT and SPI peripheral docs

// RMT strip (SK6812, non-blocking DMA)
stripWand.Show();    // queues DMA transfer, returns immediately

// SPI strip (WS2801, blocking but fast)
// 160 LEDs * 3 bytes = 480 bytes at 1MHz = ~4ms
stripSchrank.Show(); // blocks ~4ms

// RMT transfer is long enough (300*4 bytes at 800Kbps = ~9.6ms)
// that both strips finish in about the same window.

Pattern 5: Brightness Cap Enforcement

What: Scale all RGBW values by a cap factor before writing to the NeoPixelBus strip buffer. Never write full values.

// config.h
constexpr float BRIGHTNESS_CAP = 0.40f;   // 40% hardware max
constexpr float BRIGHTNESS_MAX = 0.60f;   // 60% user-settable ceiling
float g_brightness = BRIGHTNESS_CAP;       // current global brightness

// In frame output:
RgbwColor applyBrightness(RgbwColor c, float factor) {
    return RgbwColor(c.R * factor, c.G * factor,
                     c.B * factor, c.W * factor);
}

Anti-Patterns to Avoid

  • JSON parsing in AsyncUDP callback: Causes watchdog reset. Always copy bytes to queue first.
  • xTaskCreatePinnedToCore(fn, ..., 0) or ..., 1): On ESP32-C3 (single-core), core pinning is accepted without error but silently does nothing. Do not rely on it.
  • Forgetting WIFI_PS_NONE: WiFi power save mode triggers ~50ms beacon sleep cycles that cause RMT timing corruption. Must be disabled on boot.
  • Default SPI speed for WS2801: Default is 10MHz; WS2801 spec supports 25MHz but long cable runs require 1-2MHz to prevent bit errors at the far end.
  • RGB instead of RGBW for SK6812: Using NeoRgbFeature instead of NeoGrbwFeature sends 3 bytes/LED instead of 4, corrupting all LEDs after the first miscounted position.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Single-wire LED timing (SK6812) Custom bit-bang with delayMicroseconds() NeoPixelBus RMT method Bit-bang disables interrupts, guaranteed WiFi packet loss. RMT uses DMA, hardware-timed, interrupt-safe.
SPI LED output (WS2801) Raw SPI.transfer() loop with manual clock NeoPixelBus NeoWs2801SpiMethod NeoPixelBus handles SPI init, clock speed selection, and the latch timing gap automatically. NeoWs2801Spi2MhzMethod exists for cable-safe 2MHz operation.
JSON parsing Manual strstr()/atoi() parsing ArduinoJson v7 deserializeJson() JSON has edge cases (escaped chars, nested arrays, null values) that break naive parsers. ArduinoJson handles all of them.
UDP receive Polling WiFiUDP.parsePacket() in loop() AsyncUDP with onPacket() callback Polling in loop() misses packets during LED output. AsyncUDP callback fires from WiFi task, independent of loop().
Animation timing delay(20) in animation loop FreeRTOS vTaskDelayUntil() delay() is cumulative drift. vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(20)) self-corrects for task execution time, giving exact 50fps.
Color interpolation Manual lerp with map() Inline float lerp or NeoPixelBus RgbwColor blend NeoPixelBus provides RgbwColor::LinearBlend() for smooth gradient math.

Key insight: The ESP32 peripheral hardware (RMT, SPI DMA) exists precisely to handle the timing requirements that are impossible to achieve correctly in software on an interrupt-heavy single-core system. Always delegate timing to hardware.


Runtime State Inventory

Step 2.5: SKIPPED — this is a greenfield firmware project with no rename/refactor scope. No runtime state categories apply.


Environment Availability

Dependency Required By Available Version Fallback
PlatformIO CLI Build + flash ESP32 Unknown — not on this machine (VPS) Install on developer's local machine; this VPS is not the build target
Python 3 PlatformIO internals Likely (VPS has Python) Install via apt
USB serial (physical) Flash ESP32 via USB Not applicable on VPS Developer's local machine has USB port
ESP32-C3 SuperMini hardware All firmware testing Physical hardware only No emulator substitute — Wokwi can simulate limited functionality
5V power supplies (dedicated) LED strip testing Physical hardware only Cannot test strip power without hardware

Note: Firmware development and flashing happen on the developer's local machine with physical hardware, not on the groll.cloud VPS. The VPS hosts other project components (Pi-side app in later phases). This distinction means environment availability for Phase 1 is entirely hardware-dependent and cannot be verified remotely.

Missing dependencies with no fallback:

  • Physical ESP32-C3 SuperMini hardware
  • Dedicated 5V power supplies (one per strip)
  • Local machine with PlatformIO installed and USB access

Common Pitfalls

Pitfall 1: WiFi Interrupts Corrupt RMT/SK6812 Timing (CRITICAL)

What goes wrong: On the single-core ESP32-C3, WiFi's ~1ms interrupt cycle inserts ~5µs gaps into the RMT signal stream. SK6812 requires timing tolerances of ±150ns. This causes visible flickering correlated with network activity.

Why it happens: Single core. No ability to pin WiFi to core 0 and LED to core 1 (the standard ESP32 mitigation). RMT DMA buffers that are too small cause mid-frame refills — each refill is a WiFi-interruptible gap.

How to avoid:

  1. Set esp_wifi_set_ps(WIFI_PS_NONE) in setup() — eliminates beacon sleep cycle spikes
  2. Size the RMT DMA buffer to hold the full SK6812 frame: 300 LEDs × 4 bytes × 8 bits × 2 RMT symbols/bit = 19,200 RMT symbols minimum. Let NeoPixelBus size this automatically via its ShowDma method.
  3. Test with WiFi transmitting at max rate (send UDP packets rapidly from Pi) while showing a static color — stability here means the mitigation works.

Warning signs: LEDs stable with WiFi idle, flickering during UDP bursts. Flicker at a fixed ~1ms period.

Sources: FastLED issue #1657, ESP32 forum thread on RMT NeoPixels + WiFi (confirmed February 2026)


Pitfall 2: NeoPixelBus Legacy RMT Driver Conflict with Arduino Core 3.x

What goes wrong: Arduino ESP32 core 3.x (ESP-IDF 5) includes a new RMT driver (driver_ng). NeoPixelBus 2.8.x uses the legacy RMT driver. If both are linked, ESP-IDF aborts at boot: CONFLICT! driver_ng is not allowed to be used with the legacy driver.

Why it happens: The Arduino core's RGB_BUILTIN macro causes digitalWrite() to call neopixelWrite(), which links driver_ng. NeoPixelBus simultaneously links the legacy driver. Mutual exclusion causes fatal abort.

How to avoid: Add -DESP32_ARDUINO_NO_RGB_BUILTIN to build_flags in platformio.ini. This suppresses the RGB_BUILTIN macro and breaks the driver conflict.

Alternative if NeoPixelBus RMT still fails: Pin to Arduino ESP32 core 2.0.17 (platform_packages = framework-arduinoespressif32 @ 2.0.17) — this uses ESP-IDF 4 which has only the legacy RMT driver.

Warning signs: CONFLICT! driver_ng in serial monitor. Firmware boots, LEDs show garbage or nothing.


Pitfall 3: WS2801 Default SPI Speed Too High for Long Cable Run

What goes wrong: Default SPI on ESP32 is 10MHz. WS2801 supports up to 25MHz in spec, but 5m cable (effectively 10m for clock signal round-trip) adds capacitive load, smearing clock edges. Colors corrupt at far-end LEDs.

How to avoid: Use NeoWs2801Spi2MhzMethod (NeoPixelBus built-in 2MHz SPI method) instead of NeoWs2801SpiMethod. Add a 33Ω series resistor on MOSI and CLK at the ESP32 end.

Warning signs: First N LEDs show correct color, remainder show wrong or static color. Errors worsen with longer cable or higher SPI speed.


Pitfall 4: SK6812 Configured as RGB Instead of RGBW

What goes wrong: Using NeoRgbFeature (3 bytes/LED) instead of NeoGrbwFeature (4 bytes/LED) for SK6812. Every LED after position 0 receives shifted byte data, producing wrong colors throughout the strip.

How to avoid: Explicitly declare:

NeoPixelBus<NeoGrbwFeature, NeoEsp32Rmt0Ws2812xMethod> stripWand(300, PIN_SK6812);

Never use NeoRgbFeature or NeoGrbFeature for SK6812.

Detection: Set LED 0 to pure red (255,0,0,0). If LED 1 shows any glow, byte framing is wrong.


Pitfall 5: vTaskDelay(20) Drifts; Use vTaskDelayUntil() for 50fps

What goes wrong: vTaskDelay(pdMS_TO_TICKS(20)) sleeps for 20ms minimum, but does not account for the time spent computing the animation frame. Over time, the tick rate slowly drops below 50fps.

How to avoid:

void animTickTask(void* pvParams) {
    TickType_t lastWake = xTaskGetTickCount();
    while (true) {
        updateAnimations();   // compute frame
        pushToLedTask();      // signal LED output
        vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(20));  // exact 50fps
    }
}

Pitfall 6: ESP32-C3 SuperMini GPIO Constraints

What goes wrong: Several GPIOs on the SuperMini are restricted. Using them causes boot failures, flash corruption, or USB enumeration failures.

Avoid:

  • GPIO2, GPIO8, GPIO9 — strapping pins; state at boot affects boot mode
  • GPIO4-GPIO7 — JTAG pins; some sources also list these as flash control on certain board revisions
  • GPIO12-GPIO17 — internal flash (not broken out on SuperMini)
  • GPIO20-GPIO21 — UART0 (USB-to-serial for flashing)
  • GPIO8 — also connected to onboard blue LED (inverted)

Safe GPIO for peripherals: GPIO0, GPIO1, GPIO3, GPIO10 are confirmed safe. GPIO5, GPIO6, GPIO7 usable for SPI on some board revisions — verify with oscilloscope.

Recommended pin assignment (validate on hardware):

  • SK6812 RMT data: GPIO3 (safe, RMT channel 0)
  • WS2801 MOSI: GPIO7, WS2801 CLK: GPIO6 (hardware SPI)
  • Or: WS2801 MOSI: GPIO1, WS2801 CLK: GPIO0 (if JTAG conflict confirmed on 6/7)

Pitfall 7: Power Brown-Out Resets Mid-Animation

What goes wrong: 300 SK6812 LEDs at 60mA each = 18A peak. 160 WS2801 at 60mA = 9.6A. Combined ~27A. Even at 40% brightness cap (7A), a single USB supply or shared 5V rail causes voltage droop below ESP32's brown-out threshold (~3.3V), causing resets.

How to avoid: Dedicated 5V power supply per strip (not shared with ESP32 power). 1000µF cap at each strip's 5V input. Firmware brightness cap at 40%. Separate GND wire run.

Detection: ESP32 resets with rst:0x10 (RTCWDT_RTC_RESET) or rst:0x1 (POWERON_RESET) during bright white animations — classic brown-out signature.


Code Examples

Minimal NeoPixelBus Setup for Both Strips

// Source: NeoPixelBus library docs + ESP32-C3 constraints
#include <NeoPixelBus.h>

// SK6812 RGBW via RMT channel 0
// NeoGrbwFeature = 4 bytes/LED in GRBW order (SK6812 standard)
NeoPixelBus<NeoGrbwFeature, NeoEsp32Rmt0Ws2812xMethod> stripWand(300, 3);
//                                                                       ^ GPIO3

// WS2801 RGB via hardware SPI at 2MHz (cable-safe)
// NeoWs2801Spi2MhzMethod uses SPI.h internally with 2MHz clock
NeoPixelBus<NeoRgbFeature, NeoWs2801Spi2MhzMethod> stripSchrank(160);
// No data pin arg — uses default hardware SPI MOSI/CLK pins

void setup() {
    esp_wifi_set_ps(WIFI_PS_NONE);  // MUST be called after WiFi.begin()
    stripWand.Begin();
    stripSchrank.Begin();
    stripWand.Show();    // Clear to black
    stripSchrank.Show();
}

ArduinoJson v7 Command Parsing

// Source: ArduinoJson v7 documentation (bblanchon/arduinojson)

#include <ArduinoJson.h>

void handleCommand(const uint8_t* data, size_t len) {
    JsonDocument doc;  // v7: no size parameter needed
    DeserializationError err = deserializeJson(doc, data, len);
    if (err) { Serial.printf("JSON err: %s\n", err.c_str()); return; }

    int version = doc["v"] | 0;
    const char* zone = doc["zone"] | "";
    const char* cmd  = doc["cmd"]  | "";
    const char* anim = doc["animation"] | "";

    // Access params sub-object
    JsonObject params = doc["params"];
    float speed     = params["speed"]     | 1.0f;
    float intensity = params["intensity"] | 1.0f;
    // Colors: [[255,0,128,0], ...]
    JsonArray colors = params["colors"].as<JsonArray>();
}

AsyncUDP Server Setup

// Source: arduino-esp32 AsyncUDP example
#include <AsyncUDP.h>

#define UDP_PORT 4210

AsyncUDP udp;
QueueHandle_t rawQueue;  // raw bytes queue

void setupUDP() {
    rawQueue = xQueueCreate(8, sizeof(RawPacket));
    if (!udp.listen(UDP_PORT)) {
        Serial.println("UDP listen failed");
        return;
    }
    udp.onPacket([](AsyncUDPPacket pkt) {
        RawPacket raw;
        raw.len = min((size_t)512, pkt.length());
        memcpy(raw.data, pkt.data(), raw.len);
        xQueueSendFromISR(rawQueue, &raw, nullptr);
    });
}

WiFi Reconnection Strategy

// Source: Claude's discretion — standard ESP32 WiFi reconnect pattern

void wifiTask(void* pvParams) {
    WiFi.begin(SSID, PASSWORD);
    WiFi.setAutoReconnect(true);  // ESP32 handles reconnect automatically
    WiFi.persistent(false);       // Don't write credentials to flash every time

    while (true) {
        if (WiFi.status() == WL_CONNECTED) {
            // Accept new commands — UDP listener runs separately
        }
        // D-10: On disconnect, animation continues from last command.
        // No action needed here — animation task is independent.
        vTaskDelay(pdMS_TO_TICKS(5000));  // Check every 5s
    }
}

Startup Breathing Animation (D-11)

// Boot sequence: breathe at low brightness while WiFi connects
void bootBreath(uint32_t tick) {
    float t = (tick % 100) / 100.0f;  // 0-1 over 2 seconds at 50fps
    float brightness = 0.5f * (1.0f - cosf(t * 2 * PI)) * 0.15f;  // 15% max
    RgbwColor warm(brightness*255, brightness*128, 0, brightness*200);
    for (int i = 0; i < 300; i++) stripWand.SetPixelColor(i, warm);
    for (int i = 0; i < 160; i++) stripSchrank.SetPixelColor(i, RgbColor(brightness*255, brightness*128, 0));
}

State of the Art

Old Approach Current Approach When Changed Impact
FastLED with interrupt-disable for ESP32 NeoPixelBus RMT (DMA-based) 2020+ No CPU involvement during LED transfer; WiFi-safe
StaticJsonDocument<N> with fixed size JsonDocument (elastic heap) ArduinoJson v7 (2024) No buffer overflow on variable-length color arrays
ESPAsyncWebServer for UDP reception Built-in AsyncUDP 2025 (ESPAsyncWebServer archived) Fewer dependencies, more stable on C3
Dual-core WiFi/LED isolation Single-core with WIFI_PS_NONE + DMA buffers ESP32-C3 era Core pinning does not exist on C3 — different mitigation required
NeoWs2801SpiMethod (10MHz default) NeoWs2801Spi2MhzMethod (2MHz safe) NeoPixelBus 2.x Prevents cable-induced bit errors on long WS2801 runs

Deprecated/outdated:

  • FastLED.addLeds<SK6812, DATA_PIN, GRBW>() on ESP32-C3: Interrupt-disable transfer breaks WiFi; C3 RMT support in FastLED is less stable than NeoPixelBus as of early 2026.
  • StaticJsonDocument<256> (ArduinoJson v6 pattern): Replaced by elastic JsonDocument in v7.
  • ESPAsyncWebServer (me-no-dev): Archived January 2025. Use AsyncUDP directly.
  • Core pinning xTaskCreatePinnedToCore(..., core_id) on ESP32-C3: Accepted by compiler, silently ignored at runtime.

Open Questions

  1. NeoPixelBus 2.8.x with Arduino Core 3.x on physical hardware

    • What we know: There is a known RMT legacy/ng conflict. The -DESP32_ARDUINO_NO_RGB_BUILTIN flag is the documented workaround. NeoPixelBus has a CORE3 branch in development.
    • What's unclear: Whether 2.8.x + the build flag actually produces stable RMT output on real ESP32-C3 hardware, or whether downgrading to core 2.0.17 is required in practice.
    • Recommendation: First task in Wave 1 should be a minimal "blink test" that flashes both strips while sending UDP commands. If RMT output is wrong or absent, downgrade to core 2.0.17 via platform_packages override.
  2. GPIO pin assignment for simultaneous SPI + RMT on SuperMini

    • What we know: GPIO3 is safe for RMT. GPIO6/GPIO7 are listed as SPI by some sources, but also flagged as JTAG pins by others. Conflicts need hardware-level testing.
    • What's unclear: Whether GPIO6/GPIO7 conflict with JTAG usage in this specific board revision, or whether the JTAG concern is theoretical on production firmware.
    • Recommendation: Test GPIO6 (CLK) + GPIO7 (MOSI) for WS2801 first. If SPI output is corrupted, fall back to GPIO0 (CLK) + GPIO1 (MOSI).
  3. RMT DMA buffer size for 300 LED SK6812 frame

    • What we know: NeoPixelBus auto-sizes the buffer. RMT buffer = same size as pixel buffer. For 300 LEDs × 4 bytes × 8 bits × 2 RMT symbols = ~19,200 symbols.
    • What's unclear: Whether NeoPixelBus 2.8.x correctly allocates a large enough DMA buffer for 300 LEDs to prevent mid-frame refills on ESP32-C3.
    • Recommendation: Measure RMT transfer time with oscilloscope or logic analyzer on first flash. If gaps appear mid-frame, file issue with NeoPixelBus or use workaround from Issue #598.
  4. AsyncUDP response path for {"cmd":"status"} queries

    • What we know: AsyncUDP provides packet.print() / packet.write() for responses. The callback receives the sender's IP/port.
    • What's unclear: Whether xQueueSendFromISR is safe in the AsyncUDP callback, or if AsyncUDP callbacks run in a FreeRTOS task context (making regular xQueueSend fine).
    • Recommendation: Check if AsyncUDP::onPacket fires in task context (not ISR). If task context, use xQueueSend with timeout=0. Test with uxTaskGetStackHighWaterMark() to ensure parse task stack doesn't overflow.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence — flag for hardware validation)

  • GPIO6/GPIO7 JTAG conflict claim: multiple forum sources, not confirmed in official Espressif docs for this board variant
  • RMT DMA auto-sizing adequacy for 300 LED SK6812: inferred from NeoPixelBus source, not tested on physical hardware

Metadata

Confidence breakdown:

  • Standard stack: HIGH — NeoPixelBus, ArduinoJson v7, AsyncUDP all confirmed in official sources. Build flag workaround confirmed in arduino-esp32 issue tracker.
  • Architecture: HIGH — FreeRTOS three-task pattern is standard ESP32 practice; concurrent RMT+SPI output pattern confirmed in ESP32 documentation.
  • GPIO pin assignments: MEDIUM — "safe" GPIOs confirmed, specific SPI pin conflicts require hardware validation.
  • WiFi+RMT coexistence: MEDIUM — mitigation documented, effectiveness on physical hardware requires day-one validation.
  • Pitfalls: HIGH — all major pitfalls sourced from official issue trackers and forum reports with reproducible examples.

Research date: 2026-04-03 Valid until: 2026-05-03 (30 days — NeoPixelBus CORE3 branch may release updated RMT support; check repo before starting)