diff --git a/.planning/phases/01-esp32-firmware/01-RESEARCH.md b/.planning/phases/01-esp32-firmware/01-RESEARCH.md
new file mode 100644
index 0000000..70c5cbf
--- /dev/null
+++ b/.planning/phases/01-esp32-firmware/01-RESEARCH.md
@@ -0,0 +1,648 @@
+# 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 (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.
+
+
+
+---
+
+
+## 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 |
+
+
+
+---
+
+## 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):**
+
+```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
+
+### Recommended Project Structure
+
+```
+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.
+
+```cpp
+// 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.
+
+```cpp
+// 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.
+
+```cpp
+// 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.
+
+```cpp
+// 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.
+
+```cpp
+// 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:
+```cpp
+NeoPixelBus 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:**
+```cpp
+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
+
+```cpp
+// Source: NeoPixelBus library docs + ESP32-C3 constraints
+#include
+
+// SK6812 RGBW via RMT channel 0
+// NeoGrbwFeature = 4 bytes/LED in GRBW order (SK6812 standard)
+NeoPixelBus stripWand(300, 3);
+// ^ GPIO3
+
+// WS2801 RGB via hardware SPI at 2MHz (cable-safe)
+// NeoWs2801Spi2MhzMethod uses SPI.h internally with 2MHz clock
+NeoPixelBus 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
+
+```cpp
+// Source: ArduinoJson v7 documentation (bblanchon/arduinojson)
+
+#include
+
+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();
+}
+```
+
+### AsyncUDP Server Setup
+
+```cpp
+// Source: arduino-esp32 AsyncUDP example
+#include
+
+#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
+
+```cpp
+// 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)
+
+```cpp
+// 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` 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()` 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)
+
+- NeoPixelBus GitHub Wiki — ESP32 NeoMethods: https://github.com/Makuna/NeoPixelBus/wiki/ESP32-NeoMethods
+- NeoPixelBus Issue #815 — Legacy RMT driver conflict with Arduino core 3.x: https://github.com/Makuna/NeoPixelBus/issues/815
+- NeoPixelBus Issue #324 — WS2801 SPI support confirmed: https://github.com/Makuna/NeoPixelBus/issues/324
+- ArduinoJson v7 documentation (bblanchon): verified via project research STACK.md
+- arduino-esp32 AsyncUDP example: https://github.com/espressif/arduino-esp32/blob/master/libraries/AsyncUDP/examples/AsyncUDPServer/AsyncUDPServer.ino
+- FastLED Issue #1657 — ESP32-C3 WiFi+RMT flicker (active February 2026): https://github.com/FastLED/FastLED/issues/1657
+
+### Secondary (MEDIUM confidence)
+
+- NeoPixelBus Discussion #822 — RMT on ESP32-C3 Super Mini (core 2.0.17 workaround): https://github.com/Makuna/NeoPixelBus/discussions/822
+- ESP32 forum — RMT NeoPixels flicker with WiFi: https://esp32.com/viewtopic.php?t=3980
+- espboards.dev — ESP32-C3 SuperMini pinout: https://www.espboards.dev/esp32/esp32-c3-super-mini/
+- PlatformIO Community — ESP32-C3 SuperMini config: https://community.platformio.org/t/esp32-c3-supermini-config/47117
+- arduino-esp32 PR #9941 — RMT legacy driver workaround for core 3.x: https://github.com/espressif/arduino-esp32/pull/9941
+
+### 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)