# Phase 14: USB Serial Foundation and LED Control - Research

**Researched:** 2026-04-18
**Domain:** Win32 serial I/O to WLED/ESP32-C3 (Adalight binary + JSON), thread-isolated writer pattern, SteamVR driver lifecycle integration
**Confidence:** HIGH

## Summary

Phase 14 delivers the foundation of the v3.0 backglow stack: a thread-isolated USB serial pipeline from the SteamVR driver DLL to the WLED ESP32-C3 prototype (MagWLED-1) driving 10 WS2812B LEDs, with a non-negotiable software brightness ceiling and clean auto-off on `Cleanup()`. CONTEXT.md locks the design completely: Adalight binary for per-LED streaming, JSON (`{"bri":N}`/`{"on":bool}`) over the same serial port for non-streaming control, a dedicated writer thread driven by a condition variable with 33ms fallback tick, and a `backglow` pipe command namespace sent via `beyond_prox_ctl.exe`. Plan 14.1 is a pre-implementation spike mirroring the v2.0 Phase 10/10.1 cadence.

Every architectural decision is backed by the v3.0 upstream research (`.planning/research/*.md`) which is HIGH confidence and verified against the WLED source tree, Espressif docs, and existing bey-closer patterns. The one residual risk flagged in STATE.md — MagWLED-1 GPIO3 UART conflict potentially disabling serial RX — is handled by the spike's `'v'` round-trip validation (D-18 step 1). ESP32-C3 uses native USB CDC via the internal USB peripheral (not UART pins), so the concern is theoretical for the MagWLED-1 but must be confirmed on real hardware.

**Primary recommendation:** Build exactly what CONTEXT.md locks. Do not reopen Adalight-vs-TPM2, do not explore alternative threading models, do not prototype COM port auto-discovery (Phase 15). The research is exhaustive; this phase is an execution exercise against a hard-specified design.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Pipe Command Surface**
- **D-01:** Command prefix is `backglow` (matches `beyond_backglow_ctl.exe` and VRSettings naming). Replaces the `led` prefix used in some research docs.
- **D-02:** Color values are **hex strings** on the pipe wire (e.g. `FF8000`). Decimal RGB triplets rejected.
- **D-03:** `backglow fill <hex>...` is **polymorphic**:
  - 1 hex argument → all 10 LEDs set to that color (uniform fill)
  - N hex arguments → per-LED colors, one hex per LED, must equal the LED count (10 in v3.0)
  - Any other count → `ERR fill expects 1 or 10 hex values`
- **D-04:** `backglow set <index> <hex>` sets a single LED by zero-based index (`set 3 00FF00` lights LED 3 green).
- **D-05:** `backglow bri <0-255>` sets master brightness (see D-09 for clamping).
- **D-06:** `backglow off` powers all LEDs off (sends WLED `{"on":false}` JSON over the same serial port).
- **D-07:** No `backglow status` in Phase 14 — DIAG-01 is mapped to Phase 15.

**Serial Transport**
- **D-08:** **Adalight** binary protocol for per-LED color streaming, behind the `ILedTransport` interface. Format: `41 64 61 <count_hi> <count_lo> <checksum> RGB×N` (36 bytes for 10 LEDs).
- **D-08a:** Leave a TPM2 implementation as a **stub** in `src/led/` so transport can be swapped if Adalight reveals reliability issues during integration. Stub does not need to be functional in Phase 14 — just a class skeleton against `ILedTransport`.
- **D-09:** **JSON over the same serial port** for non-streaming control: `{"bri":N}` for brightness, `{"on":bool}` for power. WLED auto-detects JSON vs binary by first byte. Inter-command gap: 20ms minimum.
- **D-10:** Baud rate **fixed at 115200** (no VRSettings knob). ESP32-C3 USB CDC ignores baud anyway.
- **D-11:** Writer thread driven by **condition variable + 33ms timeout**. RunFrame/pipe handler signals on update for low-latency dispatch; thread falls back to periodic tick if no signal arrives. Minimal CPU when idle.

**Safety + Configuration**
- **D-12:** Brightness ceiling is **VRSettings-only**: `backglow.brightness_ceiling` (default `50`, range 1–255). Read at driver init and on VRSettings change. **No pipe command** to change the ceiling — protects against runaway VRChat OSC in later phases.
- **D-13:** `backglow bri <N>` clamps to ceiling and is sent as WLED master brightness via JSON `{"bri":clamped}`. WLED's master then multiplies all RGB.
- **D-14:** **COM port is VRSettings-required**: `backglow.com_port` (string, e.g. `COM5`). No scan, no auto-pick. (VID/PID auto-detect lands in Phase 15.)
- **D-15:** **Degraded startup**: if the COM port is absent/unconfigured/open-fails, log INFO once, mark backglow subsystem `disabled`, and register for hotplug notifications via `RegisterDeviceNotification` so a later plug attempts re-init. Pipe `backglow.*` commands respond `ERR backglow disabled (no port)` while disabled. Driver itself loads normally — backglow failure must never block proximity/IPD work.
- **D-16:** LEDs default **OFF** at driver startup (no auto-enable). All LEDs turn off automatically on driver `Cleanup()` (LHWD-03).

**Spike Plan Structure**
- **D-17:** **Plan 14.1 is a hardware-validation spike** before main implementation. Throwaway code in `src/spike/` + findings doc.
- **D-18:** Spike validates four specific things: `'v'` round-trip, Adalight 10-LED visible color change, unplug/replug recovery, JSON `{"bri":50}` over same port.
- **D-19:** Spike artifacts: code committed under `src/spike/backglow_spike/`, findings written to `14.1-SPIKE-FINDINGS.md`. Main impl rebuilds clean against `ILedTransport`.

**File Layout (locked)**
- **D-20:** New module: `src/led/`
  - `led_transport.h` — `ILedTransport` interface
  - `wled_serial.h/.cpp` — Adalight + JSON-over-serial implementation
  - `wled_tpm2.h/.cpp` — stub class skeleton (D-08a)
  - `led_controller.h/.cpp` — writer thread, ceiling enforcement, double-buffered state
- **D-21:** `DeviceProvider` modified: own `LedController`, route `backglow ` pipe commands via new `HandleBackglowCommand()`. Pattern matches existing `proximity`/`ipd` prefix routing.
- **D-22:** Compile-time flag `ENABLE_BACKGLOW` wraps all new code (mirrors `ENABLE_IPD_PERSIST` pattern). Default ON for Phase 14 builds.
- **D-23:** `beyond_prox_ctl.exe` extended with `backglow ` command validation. No new CLI executable in Phase 14.

### Claude's Discretion
- Exact thread-shutdown ordering on `Cleanup()` (off frame → join writer thread, vs. join → final off via main thread).
- Specific error code → log message mapping for serial errors.
- Whether the spike uses `FILE_FLAG_OVERLAPPED` from the start or starts synchronous and adds overlapping in main impl.
- Internal LED count constant (`MAX_LEDS = 10`) location — controller header vs. shared config header.
- Pipe response strings (e.g. `OK fill=FF8000 leds=10` exact wording).
- VRSettings section/key namespace formatting (`driver_BeyondProximity` section, key naming).

### Deferred Ideas (OUT OF SCOPE)
- `backglow status` pipe command → Phase 15 (DIAG-01).
- VID/PID auto-detect for COM port → Phase 15 (LHWD-04).
- WiFi/DDP fallback transport (`WledDdpTransport`) → Phase 15 (TRNS-02, TRNS-03). The `ILedTransport` interface must be designed so a second concrete implementation can plug in without refactoring.
- TPM2 functional implementation → only if Adalight reveals reliability issues (D-08a stub only otherwise).
- Configurable baud rate → out of scope (D-10).
- Per-LED brightness control → marked out-of-scope at REQUIREMENTS level.
- Idle-timeout / watchdog auto-off from MCU side → out of scope; driver `Cleanup()` is the only off trigger.
- VRChat OSC bridge daemon → Phase 16.
- Avatar prefab + reference world → Phase 17.
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| LHWD-01 | Driver communicates with WLED ESP32-C3 over USB serial using Adalight binary protocol | `src/led/wled_serial.cpp` implements Adalight framing per STACK.md §"WLED Adalight Serial Protocol"; 36-byte frame for 10 LEDs; verified against `wled_serial.cpp` upstream. |
| LHWD-02 | Driver enforces configurable global brightness ceiling (default 50/255) before any LED output | Ceiling enforced in `LedController` (above transport) per ARCHITECTURE.md §4; VRSettings-only knob per D-12; clamp applied to both `bri` command and RGB payload pre-send. |
| LHWD-03 | All LEDs turn off automatically when driver unloads or VR session ends | `DeviceProvider::Cleanup()` calls `LedController::ShutdownAllOff()` which sends one final all-black Adalight frame + `{"on":false}` JSON before joining writer thread. Mirrors HidDevice `StopReading` pattern. |
| TRNS-01 | LED communication uses abstract ILedTransport interface | Interface definition in `led_transport.h` per ARCHITECTURE.md §2; `WledSerialTransport` is the only functional impl in Phase 14; TPM2 stub (D-08a) proves interface pluggability. |
| LCTL-01 | User can set all LEDs to a single color via pipe (`backglow fill <hex>`) | Polymorphic parser per D-03; 1-arg case fans hex out to 10 LEDs before `QueueFrame`. |
| LCTL-02 | User can set individual LEDs by index (`backglow set <idx> <hex>`) | `QueueLedSet(idx, rgb)` modifies staged buffer; next frame flush sends via Adalight. |
| LCTL-03 | User can set global brightness (`backglow bri <0-255>`) | JSON `{"bri":clamped}` per D-09; clamp applied against ceiling per D-13. |
| LCTL-04 | User can turn LEDs off (`backglow off`) | JSON `{"on":false}` per D-06; optionally followed by all-black Adalight frame for belt-and-suspenders. |
| LCTL-05 | Real-time color streaming at up to 30fps | Writer thread w/ 33ms condition-variable timeout per D-11; 36 bytes/frame × 30fps = 1080 B/s, ~9% of 115200 baud. |
| DIAG-02 | Backglow settings configurable via vrsettings (brightness ceiling, COM port) | `driver_BeyondProximity.backglow_brightness_ceiling` + `.backglow_com_port` keys per D-12/D-14. |
</phase_requirements>

## Project Constraints (from CLAUDE.md)

- **Windows-only environment** — all commands/tools must be Windows-compatible.
- **cmake.exe location** fixed at `C:/Program Files/Microsoft Visual Studio/2022/Community/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe`.
- **Build command** is `<cmake.exe> --build build --config Release`.
- **Owl messaging:** Spawn `/owl listen` sessions for subagents with unique IDs; use `/owl send` for coordination.

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Win32 Serial API (CreateFile / WriteFile / SetCommState / SetCommTimeouts) | Windows SDK (always present) | USB serial I/O to WLED ESP32-C3 | Zero dependencies; project is Windows-only; WLED serial is just raw bytes over a COM port. `[CITED: STACK.md §Core: USB Serial Communication]` |
| WLED Adalight protocol | WLED 0.15.x+ (MagWLED-1 factory firmware) | Per-LED RGB streaming (36 B/frame for 10 LEDs) | WLED auto-detects by first byte `0x41 'A'`; widely documented and present in WLED source `wled_serial.cpp`. `[CITED: https://kno.wled.ge/interfaces/serial/]` |
| WLED JSON API (subset) | same | Non-streaming control: `{"bri":N}`, `{"on":bool}` | Same serial port, first-byte auto-detection (`{` = JSON). Inter-command 20 ms gap. `[CITED: STACK.md §JSON API over serial]` |
| `RegisterDeviceNotification` + message-only window | Windows SDK `user32.lib` | Serial hotplug detection when COM port comes online after driver load (D-15) | Required for degraded-startup case; `HWND_MESSAGE` window lets a DLL receive `WM_DEVICECHANGE` without a visible UI. `[VERIFIED: web search 2026-04-18]` `[CITED: learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerdevicenotificationa]` |
| Existing named-pipe server | `\\.\pipe\beyond_proximity_ctl` (already in `DeviceProvider`) | Command surface for `backglow *` | No new pipe; D-21 routes through `HandlePipeCommand()` prefix dispatch. `[VERIFIED: src/driver/device_provider.cpp:410]` |

**Version verification note:** No npm packages — this is pure Win32 C++17 on the existing project stack (C++17 / MSVC 2022 / CMake ≥ 3.20 / OpenVR SDK v2.5.1 / HIDAPI 0.14.0, all pre-existing and unchanged per STACK.md).

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| C++ STL `<thread>`, `<mutex>`, `<condition_variable>`, `<atomic>` | C++17 | Writer thread + signaled wake-up per D-11 | `LedController` writer thread uses `cv.wait_for(lock, 33ms, pred)` pattern. |
| `CM_Get_Device_Interface_List` / `SetupDiGetClassDevs` | Windows SDK (`setupapi.lib`) | COM port enumeration | **Phase 14: not required** — COM port is supplied by VRSettings per D-14. Defer SetupAPI link until Phase 15 (LHWD-04). Only `user32.lib` needed in Phase 14 for `RegisterDeviceNotification`. |
| `DEV_BROADCAST_DEVICEINTERFACE` + `GUID_DEVINTERFACE_COMPORT` | `<dbt.h>` / `<Ntddser.h>` | Hotplug filter for RegisterDeviceNotification | Match by device interface class = serial port; filters noise from unrelated USB events. `[CITED: learn.microsoft.com/en-us/windows-hardware/drivers/install/guid-devinterface-comport]` |

### Alternatives Considered
| Instead of | Could Use | Tradeoff / Why not for Phase 14 |
|------------|-----------|---------------------------------|
| Adalight | TPM2 (PITFALLS.md preference) | PITFALLS.md recommends TPM2 for bandwidth efficiency. D-08 locks Adalight because it is simpler to parse/implement, and the 36-byte frame at 115200 baud uses ~9% of bandwidth — efficiency isn't the constraint. TPM2 stub per D-08a preserves pivot optionality. |
| Adalight | WLED JSON for per-LED color | PITFALLS.md §3 documents JSON drops at 10-LED payloads. NEVER use JSON for streaming. |
| Win32 serial | libserialport / Boost.Asio | Windows-only project; Win32 is 30 lines and zero-dependency. `[CITED: STACK.md §What NOT To Add]` |
| RegisterDeviceNotification | Polling `CreateFile("\\\\.\\COMn")` each RunFrame | PITFALLS.md §"Performance Traps" explicitly flags polling COM enumeration as a CPU spike. Register-once, event-driven is correct. |
| Dedicated writer thread w/ cv | Sync writes from RunFrame | ARCHITECTURE.md §4 is emphatic: never block RunFrame. 50ms stall = judder; hang = session kill. |
| `RegisterDeviceNotification` with visible window | `HWND_MESSAGE` message-only window | DLL has no UI; message-only window is the canonical pattern. |

**Installation / link changes:**
```
# CMakeLists.txt additions (D-20 / D-22):
option(ENABLE_BACKGLOW "Enable backglow LED subsystem" ON)
if(ENABLE_BACKGLOW)
    target_sources(${DRIVER_NAME} PRIVATE
        src/led/led_transport.h
        src/led/wled_serial.h
        src/led/wled_serial.cpp
        src/led/wled_tpm2.h
        src/led/wled_tpm2.cpp
        src/led/led_controller.h
        src/led/led_controller.cpp
    )
    target_compile_definitions(${DRIVER_NAME} PRIVATE ENABLE_BACKGLOW)
    target_link_libraries(${DRIVER_NAME} PRIVATE user32)  # RegisterDeviceNotification
endif()
```
Do NOT link `setupapi.lib` in Phase 14 — deferred to Phase 15.

## Architecture Patterns

### Project Structure (per D-20)
```
src/
├── driver/           # (modified) DeviceProvider owns LedController, routes `backglow` pipe cmds
├── hid/              # (unchanged)
├── ctl/              # (modified) beyond_prox_ctl.exe gains `backglow` command validation
├── led/              # NEW
│   ├── led_transport.h      # ILedTransport interface (transport-agnostic)
│   ├── wled_serial.h/.cpp   # WledSerialTransport: Adalight + JSON-over-COM
│   ├── wled_tpm2.h/.cpp     # WledTpm2Transport: stub skeleton (D-08a)
│   └── led_controller.h/.cpp# LedController: writer thread, ceiling, staged buffer
└── spike/
    └── backglow_spike/      # NEW, throwaway — Plan 14.1 deliverable (D-18/D-19)
```

### Pattern 1: Background Thread + Atomic State (from `HidDevice`)
**What:** Blocking I/O on background thread; foreground touches only atomics or briefly-held mutex.
**When to use:** All serial I/O in `LedController` writer thread. RunFrame/pipe handler never calls `WriteFile`.
**Canonical reference:** `src/hid/hid_device.cpp:256` `ReaderThreadFunc`, atomic `m_connectionState` (line 61), `m_lastProxDistance` (line 60).

**Pseudocode — LedController:**
```cpp
// [VERIFIED: mirrors hid_device.cpp pattern lines 59-73, 256-307]
class LedController {
    std::thread m_writer;
    std::atomic<bool> m_bStop{false};
    std::atomic<int>  m_connState{0};  // 0=disabled, 1=open, 2=reconnecting
    std::mutex m_bufMutex;
    std::condition_variable m_cv;
    bool m_bDirty = false;
    uint8_t m_staged[10 * 3] = {0};       // pending frame, touched by pipe handler
    uint8_t m_ceiling = 50;               // VRSettings-driven
    std::unique_ptr<ILedTransport> m_transport;

    void WriterThread() {
        while (!m_bStop.load()) {
            uint8_t frame[30];
            bool hasFrame = false;
            {
                std::unique_lock<std::mutex> lk(m_bufMutex);
                m_cv.wait_for(lk, std::chrono::milliseconds(33),
                              [&]{ return m_bDirty || m_bStop.load(); });
                if (m_bStop.load()) break;
                if (m_bDirty) {
                    std::memcpy(frame, m_staged, sizeof(frame));
                    m_bDirty = false;
                    hasFrame = true;
                }
            }
            if (hasFrame) {
                // Apply ceiling: clamp every channel value to (val * ceiling / 255)
                // so physical LEDs never exceed the configured max.
                ApplyCeiling(frame, m_ceiling);
                if (!m_transport->SendRgbFrame(frame, 10)) {
                    HandleTransportError();  // triggers reconnect cycle
                }
            }
        }
    }

    void ShutdownAllOff() {
        // Called from DeviceProvider::Cleanup() BEFORE joining writer thread.
        // Synchronously push an all-black Adalight frame + {"on":false} JSON.
        // See Claude's Discretion note on ordering.
    }
};
```

### Pattern 2: Adalight Frame Encoder
**Source:** `[CITED: https://www.partsnotincluded.com/visualizing-adalight-header-information/]` + WLED `wled_serial.cpp`
```cpp
// For N=10 LEDs: count = N-1 = 9
// Header: 'A' 'd' 'a' count_hi count_lo (count_hi ^ count_lo ^ 0x55)
// Payload: R0 G0 B0 R1 G1 B1 ... R9 G9 B9
// Total: 6 + 30 = 36 bytes
static void BuildAdalightFrame(const uint8_t* rgb, int numLeds, std::vector<uint8_t>& out) {
    const uint16_t count = static_cast<uint16_t>(numLeds - 1);
    const uint8_t hi = static_cast<uint8_t>(count >> 8);
    const uint8_t lo = static_cast<uint8_t>(count & 0xFF);
    out.clear();
    out.reserve(6 + numLeds * 3);
    out.push_back('A'); out.push_back('d'); out.push_back('a');
    out.push_back(hi);  out.push_back(lo);
    out.push_back(static_cast<uint8_t>(hi ^ lo ^ 0x55));
    out.insert(out.end(), rgb, rgb + numLeds * 3);
}
```

### Pattern 3: Win32 COM Port Open with Overlapped I/O
**Source:** `[CITED: PITFALLS.md §1]`
```cpp
// Name must be \\.\COMn for COM10+; safe to use for COM1-9 too.
std::string path = "\\\\.\\" + portName;    // e.g. "\\\\.\\COM5"
HANDLE h = CreateFileA(path.c_str(),
    GENERIC_READ | GENERIC_WRITE,
    0,                        // exclusive access
    nullptr,
    OPEN_EXISTING,
    FILE_FLAG_OVERLAPPED,     // non-blocking I/O
    nullptr);
if (h == INVALID_HANDLE_VALUE) { /* log + mark disabled per D-15 */ }

DCB dcb = {0}; dcb.DCBlength = sizeof(dcb);
GetCommState(h, &dcb);
dcb.BaudRate = CBR_115200;     // D-10: fixed
dcb.ByteSize = 8;
dcb.Parity   = NOPARITY;
dcb.StopBits = ONESTOPBIT;
dcb.fBinary  = TRUE;
dcb.fDtrControl = DTR_CONTROL_DISABLE;  // avoid ESP32-C3 reset on DTR toggle
dcb.fRtsControl = RTS_CONTROL_DISABLE;
SetCommState(h, &dcb);

COMMTIMEOUTS to = {0};
to.WriteTotalTimeoutConstant   = 50;      // hard cap (PITFALLS.md §1)
to.WriteTotalTimeoutMultiplier = 0;
to.ReadIntervalTimeout         = MAXDWORD;
to.ReadTotalTimeoutMultiplier  = 0;
to.ReadTotalTimeoutConstant    = 10;
SetCommTimeouts(h, &to);
```

### Pattern 4: Pipe Command Prefix Routing (existing)
**Canonical ref:** `device_provider.cpp:410-530` — adds exactly one new `else if (strncmp(cmd, "backglow ", 9) == 0)` branch delegating to `HandleBackglowCommand()`. Do NOT refactor existing dispatch; extend it.

### Pattern 5: Message-Only Window for RegisterDeviceNotification in a DLL
**Purpose:** Phase 14 D-15 requires hotplug detection when COM port arrives AFTER driver loaded. DLL has no UI — use `HWND_MESSAGE`.

```cpp
// [CITED: learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerdevicenotificationa]
// Register a window class with a WndProc that handles WM_DEVICECHANGE,
// CreateWindowExA(..., HWND_MESSAGE, ...) to get a message-only window,
// then RegisterDeviceNotification(hwnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE)
// where filter is DEV_BROADCAST_DEVICEINTERFACE with classguid = GUID_DEVINTERFACE_COMPORT.
// Run a small PeekMessage/DispatchMessage loop on a dedicated thread (owned by LedController)
// OR piggyback on the writer thread's cv wait by using MsgWaitForMultipleObjects.
// Simpler path: a dedicated "hotplug thread" spun up only when backglow starts in disabled state.
```

**Claude's discretion flag:** implementation may defer hotplug thread creation until the port is first detected missing (lazy), or always create it at Init. Either is within D-15's intent.

### Anti-Patterns to Avoid
- **Writing serial from RunFrame** — PITFALLS.md §1; stalls ALL SteamVR drivers. ABSOLUTE PROHIBITION.
- **JSON for per-LED color** — PITFALLS.md §3; silent drops. JSON reserved for `bri` + `on` only, with ≥20 ms gap.
- **Opening COMn without `\\.\` prefix** — Breaks on COM10+. Always use prefixed path. `[CITED: https://support.microsoft.com/en-us/topic/howto-specify-serial-ports-larger-than-com9-db9078a5-b7b6-bf00-240f-f749ebfd913e]`
- **Toggling DTR on open** — Many Arduino/ESP32 boards reset when DTR goes HIGH→LOW at open. ESP32-C3 native USB is less prone but keep `fDtrControl = DTR_CONTROL_DISABLE` to be safe.
- **Propagating serial errors to RunFrame** — Must stay internal to writer thread, same rule as HidDevice reader. `[CITED: PITFALLS.md §6]`
- **Hardcoded COM port anywhere** — VRSettings only (D-14). PITFALLS.md §2.
- **Blocking in `Cleanup()` for more than writer-thread-join + one final serial frame** — SteamVR gives drivers a bounded shutdown window; one 36-byte Adalight frame + short JSON = <50 ms is fine; waiting 5 s for reconnect is NOT fine.
- **Default-on LEDs at driver Init** — D-16. Pipe handler only sends frames after an explicit command.
- **Adding a second named pipe** — ARCHITECTURE.md §5 Anti-Pattern: one pipe, one control surface.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Adalight checksum / framing | Custom state machine | 6-byte header formula `hi ^ lo ^ 0x55` is trivial; one function | Already minimal; hand-rolling "more" would add bugs. |
| Serial port open / baud config | Library wrapper | `CreateFile` + `SetCommState` + `SetCommTimeouts` (30 lines) | `[CITED: STACK.md §Core]` Project is Windows-only; no cross-platform value. |
| COM port name prefix | Hand-maintained list | Always prefix with `\\.\` for all COMn | Microsoft doc mandates this for COM10+; doing it for all is safe. `[CITED: MS support article]` |
| Hotplug detection | Poll loop | `RegisterDeviceNotification` + `WM_DEVICECHANGE` | Polling burns CPU and triggers SetupAPI enumeration each tick per PITFALLS.md. |
| WLED JSON packet | External JSON lib | `snprintf(buf, sz, "{\"bri\":%u}\n", val)` | Payload is trivial (two keys total: `bri`, `on`). Avoid pulling in nlohmann/rapidjson for two strings. |
| LED brightness ceiling | Rely on WLED firmware cap | Driver-side `clamp_rgb(r,g,b, ceiling)` BEFORE transport | PITFALLS.md §5: defense-in-depth; WLED firmware limit is not sufficient guarantee. Ceiling applied by `LedController`, above transport. |
| Hex-string parsing | Custom multi-char parser | `std::strtoul(hex, nullptr, 16)` and validate length==6 | Standard library handles it. |

**Key insight:** Every "don't hand-roll" in this list is not about avoiding libraries — it's about avoiding reinventing the standard Win32/WLED patterns documented upstream.

## Common Pitfalls

### Pitfall 1: Blocking Serial Writes in RunFrame Kill VR
**From PITFALLS.md §1 — HIGH severity.**
**What goes wrong:** `WriteFile` on USB CDC can block 1–50 ms on buffer-full or indefinitely on disconnect. Any RunFrame stall blocks **all** SteamVR drivers sharing vrserver's main thread — visible judder at 50 ms, session kill on hang.
**How to avoid:** Dedicated writer thread (D-11), `FILE_FLAG_OVERLAPPED`, `WriteTotalTimeoutConstant=50ms`. RunFrame only signals the condition variable.
**Warning signs:** Any `CreateFileA` / `WriteFile` in `device_provider.cpp` reachable from `RunFrame` or `HandlePipeCommand`.

### Pitfall 2: WLED JSON Drops at 10-LED Payloads
**From PITFALLS.md §3.**
**What goes wrong:** ESP32-C3 USB CDC buffer is small; large JSON silently truncates and WLED may apply partial state or ignore.
**How to avoid:** JSON reserved for `{"bri":N}` and `{"on":bool}` — both <20 bytes. Inter-command gap ≥20 ms. Per-LED streaming goes through Adalight.

### Pitfall 3: COM Port Name Without `\\.\` Prefix
**What goes wrong:** `CreateFileA("COM10", ...)` fails on any COM port ≥ 10.
**How to avoid:** Always prefix with `\\\\.\\` (C-literal escape for `\\.\`). Do it unconditionally for COM1–COM256.
**[CITED: https://support.microsoft.com/en-us/topic/howto-specify-serial-ports-larger-than-com9-db9078a5-b7b6-bf00-240f-f749ebfd913e]**

### Pitfall 4: ESP32-C3 USB Disconnect Error Codes
**From PITFALLS.md §6.**
**What goes wrong:** Unplug / sleep / OTA mid-write returns error codes the writer must handle explicitly.
**Error codes to handle:** `ERROR_GEN_FAILURE` (31), `ERROR_DEVICE_NOT_CONNECTED` (1167), `ERROR_OPERATION_ABORTED` (995), `ERROR_BAD_COMMAND` (22).
**How to avoid:** On any error → close handle, set `m_connState = 2` (reconnecting), log at INFO, wait N seconds, attempt reopen (same pattern as HidDevice reader). Never propagate to RunFrame.

### Pitfall 5: GPIO3 UART Conflict on MagWLED-1
**From STATE.md "Blockers/Concerns" + SUMMARY.md "Gaps".**
**What goes wrong:** If WLED allocates GPIO3 for LED data output on MagWLED-1, the ESP32-C3 UART RX is disabled, and serial input stops working.
**Mitigation in Phase 14:** ESP32-C3 native USB uses the internal USB peripheral (GPIO18/19), NOT UART pins — the serial CDC path should not be affected by GPIO3 assignment. **Spike step 1 (`'v'` round-trip) validates this directly.** If `'v'` fails on MagWLED-1, the LED subsystem logs INFO and marks disabled — the driver still loads.
**Warning signs:** `'v'` byte sent, no reply within 2 s, despite a visibly-open COM port.

### Pitfall 6: Thermal / Brightness Safety
**From PITFALLS.md §5 — SAFETY CRITICAL.**
**What goes wrong:** 10× WS2812B at full white = 3 W dissipated directly against the face.
**How to avoid:** Brightness ceiling (default 50/255, D-12) applied in `LedController` BEFORE any transport write. Defense-in-depth with WLED's own master `bri`. Default OFF at startup (D-16).

### Pitfall 7: Residual Glow on Unclean Shutdown
**Trigger for LHWD-03.**
**What goes wrong:** WS2812B latch their last color indefinitely. If driver crashes or SteamVR kills vrserver without running `Cleanup`, LEDs stay on.
**How to avoid (Phase 14 scope):** Clean shutdown path — `Cleanup()` sends an all-black Adalight frame + `{"on":false}` JSON, waits briefly for writer-thread drain, then joins. Hardware watchdog / MCU-side auto-off is explicitly out of scope (deferred, see `<deferred>`). Phase 14 cannot guarantee residual-glow-free behavior on `TerminateProcess`/crash — that requires MCU-side logic, which is out of scope.
**Verification:** Runtime State Inventory below.

## Runtime State Inventory

Phase 14 is greenfield (net-new subsystem). This section is **not** required by the template, but it is included because the LED state is physical/hardware-side and therefore functions like "stored state" outside the process boundary.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | None — no databases or state files owned by the backglow subsystem. (`backglow.com_port` / `backglow.brightness_ceiling` live in steamvr.vrsettings, managed by SteamVR.) | None |
| Live service config | **WLED ESP32-C3 firmware config on MagWLED-1 device:** WLED's own Sync Settings must have `Serial` enabled and "realtime" allow-list set. Default stock WLED has this on — verify in spike (step 2). | Spike verifies; main plan references findings doc. |
| OS-registered state | **COM port mapping** in Windows Device Manager: ESP32-C3 VID `0x303A`/PID `0x1001` enumerates as a USB composite device, with the CDC child getting a COM number. This is OS-managed; driver just reads `backglow.com_port` from VRSettings. | None — user configures once via VRSettings. |
| Secrets / env vars | None | None |
| Build artifacts | **`driver_BeyondProximity.dll`** gains new `.obj` dependencies under `ENABLE_BACKGLOW`. **Installer** (Phase 9 Inno Setup) has no new files to bundle — everything ships inside the driver DLL. | `cmake --build` rebuilds cleanly; no installer change needed for Phase 14. |
| **Physical LED state (non-standard)** | **WS2812B latches:** LEDs retain last-set color until power-cycle or explicit zero-write. This is hardware state that survives driver crash. | `Cleanup()` sends all-black + `{"on":false}`; crash case out of scope (Phase 14 best-effort only). |

## Code Examples

### 1. Adalight frame encode + write (Pattern 2 applied)
```cpp
// src/led/wled_serial.cpp (prototype)
// [CITED: https://kno.wled.ge/interfaces/serial/] — protocol
bool WledSerialTransport::SendRgbFrame(const uint8_t* rgb, int numLeds) {
    uint8_t buf[6 + 10 * 3];  // MAX_LEDS=10 in v3.0
    const uint16_t count = static_cast<uint16_t>(numLeds - 1);
    buf[0] = 'A'; buf[1] = 'd'; buf[2] = 'a';
    buf[3] = static_cast<uint8_t>(count >> 8);
    buf[4] = static_cast<uint8_t>(count & 0xFF);
    buf[5] = static_cast<uint8_t>(buf[3] ^ buf[4] ^ 0x55);
    std::memcpy(buf + 6, rgb, numLeds * 3);
    return WriteAllWithTimeout(buf, sizeof(buf));
}
```

### 2. JSON brightness / power (D-09, D-13)
```cpp
// Send {"bri":N} or {"on":bool} as a single line. Inter-command gap enforced upstream.
bool WledSerialTransport::SendBrightness(uint8_t v) {
    char line[32];
    int n = std::snprintf(line, sizeof(line), "{\"bri\":%u}\n", (unsigned)v);
    return WriteAllWithTimeout(reinterpret_cast<uint8_t*>(line), static_cast<size_t>(n));
}
bool WledSerialTransport::SendPower(bool on) {
    const char* line = on ? "{\"on\":true}\n" : "{\"on\":false}\n";
    return WriteAllWithTimeout(reinterpret_cast<const uint8_t*>(line), std::strlen(line));
}
```

### 3. Hex-string parse for `backglow fill`/`set` (D-02, D-03, D-04)
```cpp
// Parse a 6-char hex string "RRGGBB" into 3 bytes. Reject anything else.
static bool ParseHexColor(const char* s, size_t len, uint8_t& r, uint8_t& g, uint8_t& b) {
    if (len != 6) return false;
    for (size_t i = 0; i < 6; ++i)
        if (!std::isxdigit(static_cast<unsigned char>(s[i]))) return false;
    unsigned long v = std::strtoul(std::string(s, 6).c_str(), nullptr, 16);
    r = (v >> 16) & 0xFF;
    g = (v >> 8)  & 0xFF;
    b =  v        & 0xFF;
    return true;
}
```

### 4. Polymorphic fill (D-03)
```cpp
// "backglow fill FF8000" OR "backglow fill C1 C2 ... C10"
void HandleBackglowFill(const char* args, char* resp, size_t rsz) {
    std::vector<std::array<uint8_t,3>> colors;
    // tokenize args by whitespace, parse each as 6-hex
    // ... (standard tokenization)
    if (colors.size() == 1) {
        // uniform fan-out to all 10 LEDs
        uint8_t rgb[30];
        for (int i = 0; i < 10; ++i) std::memcpy(rgb + i*3, colors[0].data(), 3);
        m_pLedController->QueueFrame(rgb, 10);
        snprintf(resp, rsz, "OK fill=%02X%02X%02X leds=10",
                 colors[0][0], colors[0][1], colors[0][2]);
    } else if (colors.size() == 10) {
        uint8_t rgb[30];
        for (int i = 0; i < 10; ++i) std::memcpy(rgb + i*3, colors[i].data(), 3);
        m_pLedController->QueueFrame(rgb, 10);
        snprintf(resp, rsz, "OK fill=per-led leds=10");
    } else {
        snprintf(resp, rsz, "ERR fill expects 1 or 10 hex values");
    }
}
```

### 5. VRSettings read at Init (D-12, D-14, DIAG-02)
```cpp
// In DeviceProvider::Init, read backglow config. Pattern mirrors existing
// report_rate_ms / log_verbosity reads in device_provider.cpp lines 32-54.
vr::EVRSettingsError err;
int32_t ceiling = vr::VRSettings()->GetInt32(kSettingsSection,
    "backglow_brightness_ceiling", &err);
if (err != vr::VRSettingsError_None) ceiling = 50;  // default per D-12
ceiling = std::clamp(ceiling, 1, 255);

char portBuf[64] = {0};
vr::VRSettings()->GetString(kSettingsSection, "backglow_com_port",
    portBuf, sizeof(portBuf), &err);
std::string port = (err == vr::VRSettingsError_None) ? portBuf : "";
// If port empty or open fails → degraded mode per D-15.
```

### 6. Cleanup ordering (Claude's Discretion — recommended)
```cpp
void DeviceProvider::Cleanup() {
#ifdef ENABLE_IPD_PERSIST
    PersistIpdToConfig();
#endif
    DestroyPipeServer();

#ifdef ENABLE_BACKGLOW
    if (m_pLedController) {
        m_pLedController->ShutdownAllOff();  // synchronous: black frame + {"on":false}
        m_pLedController->Stop();            // join writer thread
        m_pLedController.reset();
    }
#endif

    if (m_pHidDevice) m_pHidDevice->StopReading();
    m_pHidDevice.reset();
    hid_exit();
    CleanupDriverLog();
}
```
**Rationale:** `ShutdownAllOff` runs on the main thread while the writer is still alive, pushes one last frame, then the writer thread is joined cleanly. Alternative (signal writer to send final frame, then join) introduces a race where the writer might loop around to block on reconnect. The synchronous variant is simpler and SteamVR's Cleanup timeout is generous enough for a 36-byte write + 12-byte JSON.

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| USB UART chip (CP2102/CH340) on ESP32 boards | Native USB Serial/JTAG on ESP32-C3 (no UART chip) | ESP32-C3 launch (~2021) | Windows 10/11 auto-enumerate as CDC; no driver install. `[CITED: https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-guides/usb-serial-jtag-console.html]` |
| WLED JSON over serial for per-LED color | Adalight / TPM2 binary | ~WLED 0.12 | JSON is still there but is 2-4× heavier and drops at speed. `[CITED: kno.wled.ge/interfaces/serial]` |
| Sync serial writes from main thread | Dedicated writer thread + cv | N/A (codebase-specific) | HidDevice established the pattern in v1.0. Re-apply here. |

**Deprecated / outdated in the research docs vs. CONTEXT.md:**
- STACK.md `led ...` command prefix → superseded by `backglow ...` per D-01.
- ARCHITECTURE.md §4 bandwidth check "no dedicated writer thread needed" (contradicts D-11 and PITFALLS.md §1) → **D-11 is authoritative.** Dedicated writer thread IS required.
- PITFALLS.md §3 "use TPM2 instead of JSON" preference → D-08 overrides: Adalight is primary, TPM2 is stub.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | ESP32-C3 native USB CDC on MagWLED-1 ignores DCB baud setting but still honors framing — `dcb.BaudRate = CBR_115200` is safe / harmless. | Pattern 3 | LOW — if the stack ignores baud entirely, setting it does nothing; if it respects it, 115200 matches WLED. Either way, no breakage. |
| A2 | `FILE_FLAG_OVERLAPPED` + `WriteTotalTimeoutConstant=50` reliably bounds `WriteFile` duration on ESP32-C3 USB CDC. | Pattern 3 | MEDIUM — spike step 3 (unplug/replug) stresses this. If unbounded blocking still occurs, writer thread wraps writes in `WriteFile` + `WaitForSingleObject(overlappedEvent, 50ms)` + `CancelIoEx` on timeout. |
| A3 | WLED's first-byte auto-detection between `'A'` (Adalight) and `'{'` (JSON) handles interleaved sequencing: Adalight frame, 20ms gap, JSON, 20ms gap, Adalight frame. | D-09 | LOW — explicitly claimed by WLED docs; spike step 4 confirms. |
| A4 | MagWLED-1 WLED build does NOT have GPIO3 allocated for LED data in a way that breaks serial RX (because ESP32-C3 native USB uses internal USB peripheral, not UART pins). | Pitfall 5 | MEDIUM — if wrong, spike step 1 catches it; driver gracefully degrades per D-15. |
| A5 | SteamVR calls `Cleanup()` before killing vrserver under normal shutdown (clean exit via SteamVR UI, driver unload). | Cleanup pattern | HIGH — this is the mechanism for LHWD-03. If `Cleanup()` is NOT called (e.g., `TerminateProcess`), LEDs latch last state. Out of scope per `<deferred>`. |
| A6 | The `DEV_BROADCAST_DEVICEINTERFACE` + `GUID_DEVINTERFACE_COMPORT` filter works for ESP32-C3 composite device (CDC child registers the COMPORT interface GUID). | D-15 hotplug | MEDIUM — Espressif composite device exposes the CDC child as a standard COM interface, so GUID filter should match. Confirmed by behavior of other WLED integrations. If miss, fallback is `DBT_DEVTYP_PORT` broadcast (no filter). |
| A7 | One `backglow off` JSON + one all-black Adalight frame is sufficient for visible-off state on WLED. | Pattern 6 (Cleanup) | LOW — both paths independently turn off output; either alone should suffice. Belt-and-suspenders. |

## Open Questions (RESOLVED)

All four questions below have inline recommendations that are implemented in the Phase 14 plans:
- ShutdownAllOff timing: synchronous direct-write in LedController (Plan 14-02 Task 2)
- Hotplug debounce: `Sleep(500)` in `OnHotplugArrival` (Plan 14-03 Task 1)
- Response wording: `OK <verb>=<value>` / `ERR <message>` specified in plan interfaces blocks
- `backglow off`/`on` state: `m_bPoweredOn` tracking with auto-`SendPower(true)` before frame (Plan 14-02 Task 2)

1. **Exact wait duration for `ShutdownAllOff`.**
   - What we know: writer thread sleeps up to 33 ms on cv; one full round-trip (signal + frame write) is typically <50 ms.
   - What's unclear: Should `ShutdownAllOff` be fully synchronous on the main thread (bypass writer entirely) or signal writer and wait up to N ms?
   - Recommendation: Synchronous direct-write from the main thread BEFORE joining the writer (see Pattern 6). Simpler, no race.

2. **Hotplug re-init retry cadence (D-15).**
   - What we know: Register for notifications, receive `WM_DEVICECHANGE`/`DBT_DEVICEARRIVAL`.
   - What's unclear: On arrival, immediately attempt to open the configured COM port, or debounce 500 ms?
   - Recommendation: 500 ms debounce — some USB composite devices take a moment for the CDC child to fully enumerate.

3. **Per-command response wording.**
   - Marked Claude's Discretion in CONTEXT.md — planner should converge on a consistent style. Recommend: `OK <verb>=<value> [<key>=<value>]` and `ERR <message>` to match existing `proximity`/`ipd` responses in `device_provider.cpp:418-424`.

4. **`backglow off` effect on brightness state.**
   - What we know: `{"on":false}` tells WLED to stop driving LEDs; master `bri` is preserved.
   - What's unclear: After `backglow off` then `backglow fill FFFFFF`, does the driver need to send `{"on":true}` first?
   - Recommendation: Yes — track `m_bPoweredOn` in `LedController`; auto-send `{"on":true}` before any streaming frame if it was previously off. Verify in spike.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| MSVC 2022 + cmake.exe | Build | ✓ | CMake path in CLAUDE.md | — |
| Windows SDK (Win32 serial / user32 / dbt.h) | Build + runtime | ✓ | Ships with MSVC | — |
| HIDAPI (vendored, unchanged) | Existing HID subsystem | ✓ | 0.14.0 in extern/hidapi | — |
| OpenVR SDK (vendored, unchanged) | Driver ABI | ✓ | v2.5.1 in extern/openvr | — |
| MagWLED-1 hardware w/ WLED firmware | Hardware validation (spike + UAT) | Must be physically connected for Plans 14.1 / phase verify | WLED 0.15.x (factory) | No fallback — hardware gate. Spike plan explicitly tests against real hardware. |
| COM port assigned by Windows | Runtime | Provided by MagWLED-1 enumeration; confirmed by user in VRSettings (`backglow.com_port`) | Dynamic (typically COM3-COM20) | D-15 degraded mode: driver loads, backglow disabled, hotplug awaits device arrival. |
| Camera stream for agent UAT (vdo.ninja) | Optional visual verification of LED changes during UAT | User can enable on demand: https://vdo.ninja/?view=JYMW97gq | — | Without stream: ask user for visual confirmation verbally / screenshot. |

**Missing dependencies with fallback:**
- If the MagWLED-1 isn't plugged in during planning or pre-spike dry runs, the D-15 degraded-startup path means the driver can still be built, loaded, and all non-backglow subsystems (proximity, IPD) continue to function. Unit-style tests on the Adalight encoder + hex parser + brightness clamp require no hardware.

**Missing dependencies, blocking:**
- None for building. Hardware is blocking for *hardware-visible* spike steps (D-18.2, D-18.3) and for phase verification's success criteria 1/3/4.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | **None existing** — project currently has no unit-test harness (verified by `grep -i test` on `CMakeLists.txt` returning zero matches, and absence of `tests/` / `test/` directories). Manual + hardware-visible verification is the established pattern for this codebase (see Phase 1–13 completion history). |
| Config file | none — see Wave 0 |
| Quick run command | `"C:/Program Files/Microsoft Visual Studio/2022/Community/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe" --build build --config Release` — compiles and validates. Successful build + `driver_BeyondProximity.dll` load in SteamVR + `beyond_prox_ctl "status"` returning non-error is the de-facto "quick test". |
| Full suite command | Same build + hardware smoke sequence (documented below per requirement). |
| Phase gate | All success criteria verified on real hardware (MagWLED-1) before `/gsd-verify-work`. |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| LHWD-01 | Adalight frame drives 10 physical LEDs | hardware-visible smoke | `beyond_prox_ctl "backglow fill FF0000"` → all 10 LEDs red (visually verified, optionally via vdo.ninja stream) | N/A — manual |
| LHWD-02 | Brightness ceiling silently clamps above threshold | hardware-visible smoke + logic | Set VRSettings `backglow_brightness_ceiling=20`, send `backglow bri 255`; observe clamped brightness in logs + LED output | N/A — manual + log inspection |
| LHWD-02 (logic only) | Ceiling clamp math on RGB | **unit-test candidate** — pure function `ApplyCeiling(rgb, ceiling)` | Would need framework — see Wave 0 gap | No — spike can include a tiny manual-test harness in `src/spike/backglow_spike/` |
| LHWD-03 | LEDs auto-off on driver Cleanup | hardware-visible | Exit SteamVR → LEDs go dark within ~1 s | N/A — manual |
| TRNS-01 | ILedTransport interface is pluggable | compile-time + code review | `wled_tpm2.cpp` stub compiles against `ILedTransport` interface unmodified from `wled_serial.cpp`'s base class | Automatic via `cmake --build` |
| LCTL-01 | `backglow fill <hex>` sets all 10 LEDs | hardware-visible smoke | `beyond_prox_ctl "backglow fill 00FF00"` → 10 green | N/A — manual |
| LCTL-02 | `backglow set <idx> <hex>` sets one LED | hardware-visible smoke | `beyond_prox_ctl "backglow set 3 0000FF"` → only LED 3 blue | N/A — manual |
| LCTL-03 | `backglow bri <N>` adjusts master | hardware-visible smoke | `backglow bri 100` then `backglow bri 20` — visual change | N/A — manual |
| LCTL-04 | `backglow off` turns LEDs dark | hardware-visible smoke | `beyond_prox_ctl "backglow off"` → all dark | N/A — manual |
| LCTL-05 | 30fps streaming works | hardware-visible + timing | Script sending `backglow fill` in loop for 10s; driver log shows no frame drops; LEDs appear to update smoothly | N/A — manual/scripted |
| DIAG-02 | VRSettings `backglow.brightness_ceiling` + `backglow.com_port` are read | behavior test | Set values in `steamvr.vrsettings`, restart SteamVR, verify driver log reports them | N/A — manual + log inspection |

### Sampling Rate
- **Per task commit:** `cmake --build build --config Release` (≤ 30s for incremental). Unit-testable pure functions (ceiling clamp, hex parser, Adalight encoder) can be exercised via a tiny `src/spike/backglow_spike/harness.cpp` — this also fits the spike's throwaway-code posture.
- **Per wave merge:** Full build + hardware smoke for each LCTL requirement via MagWLED-1.
- **Phase gate:** Complete hardware UAT covering all 5 success criteria from ROADMAP.md §Phase 14; findings file `14-VERIFICATION.md` records observed behavior.

### Wave 0 Gaps
- [ ] **No unit test framework exists.** Introducing Catch2/doctest would be scope-creep for Phase 14. **Recommendation:** put pure-logic sanity checks (Adalight encoder, hex parser, ceiling clamp) inline in `src/spike/backglow_spike/harness.cpp` as assert-style checks — already within D-17/D-19 spike scope. This keeps the phase's Validation Architecture honest without pulling in a test framework that no prior phase needed.
- [ ] **No CI harness.** The project relies on local `cmake --build` + manual hardware smoke. No change recommended for Phase 14.
- [ ] **No mock serial transport.** Not needed: `ILedTransport` already provides the seam; if a future phase wants automated tests, a `MockLedTransport` can be written against the interface without touching consumers.

*Nyquist validation note: `workflow.nyquist_validation: true` is set in `.planning/config.json`. Phase 14 honors this by mapping every requirement to a concrete verification command/procedure even though no automated framework is available — manual smoke is the verification modality of record for this codebase, consistent with all 13 shipped phases to date.*

## Sources

### Primary (HIGH confidence)
- `.planning/research/SUMMARY.md` — v3.0 executive summary (2026-04-05)
- `.planning/research/STACK.md` — Adalight byte layout, WLED baud commands, JSON-over-serial behavior, ESP32-C3 USB CDC details
- `.planning/research/ARCHITECTURE.md` §1–§7 — LED subsystem architecture, threading model, file layout, build order, anti-patterns
- `.planning/research/PITFALLS.md` §1–§7 — blocking serial, COM enumeration fragility, JSON unreliability, brightness safety, disconnect handling, WLED coupling
- `.planning/REQUIREMENTS.md` — full v3.0 requirement list and Phase 14 mapping
- `.planning/phases/14-usb-serial-foundation-and-led-control/14-CONTEXT.md` — locked decisions (D-01 through D-23)
- WLED Serial Interface: https://kno.wled.ge/interfaces/serial/
- WLED JSON API: https://kno.wled.ge/interfaces/json-api/
- WLED serial source: https://github.com/Aircoookie/WLED/blob/main/wled00/wled_serial.cpp
- Adalight header format: https://www.partsnotincluded.com/visualizing-adalight-header-information/
- ESP32-C3 USB Serial/JTAG Console: https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-guides/usb-serial-jtag-console.html
- Windows COM port `\\.\COMx` prefix requirement: https://support.microsoft.com/en-us/topic/howto-specify-serial-ports-larger-than-com9-db9078a5-b7b6-bf00-240f-f749ebfd913e
- `RegisterDeviceNotificationA`: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerdevicenotificationa
- `GUID_DEVINTERFACE_COMPORT`: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/guid-devinterface-comport
- MagWLED-1 product page: https://magwled.com/pages/about-magwled-1

### Existing codebase (HIGH confidence)
- `src/hid/hid_device.h/.cpp` — canonical background-thread + atomic-state + reconnect pattern
- `src/driver/device_provider.h/.cpp` lines 112 (Cleanup), 278 (RunFrame), 410 (HandlePipeCommand), 14 (settings section const)
- `src/ctl/main.cpp` — pipe client command-validation pattern
- `CMakeLists.txt` lines 38-56 (target sources), 60-62 (ENABLE_IPD_PERSIST pattern)

### Secondary (MEDIUM confidence, verified via web search 2026-04-18)
- ESP32-C3 VID `0x303A` / PID `0x1001` composite-device enumeration on Windows — confirmed by multiple Espressif forum threads and ESP-IDF docs
- `HWND_MESSAGE` windowless device notification in a DLL — widely used pattern; specific example `qdevicewatcher_win32.cpp` linked from web search

### Tertiary (LOW confidence — flagged for spike validation)
- Assumption A4 (MagWLED-1 GPIO3 does not break serial RX via native USB) — validated by Plan 14.1 spike step 1
- Assumption A6 (GUID_DEVINTERFACE_COMPORT matches ESP32-C3 composite CDC child) — validated by Plan 14.1 spike step 3 (unplug/replug)

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every choice already verified and locked upstream; zero speculative libraries.
- Architecture: HIGH — mirrors HidDevice pattern exactly; all anti-patterns documented.
- Pitfalls: HIGH — 7 distinct pitfalls already identified in upstream research; Phase 14 spike (14.1) specifically validates the highest-risk items (GPIO3 conflict, disconnect recovery, JSON/binary coexistence).
- Validation architecture: MEDIUM — no existing unit-test framework; manual + hardware-visible smoke is codebase-canonical.

**Research date:** 2026-04-18
**Valid until:** 2026-05-18 (30 days — domain is stable; WLED 0.15.x firmware and Win32 APIs change slowly)
